feat: port basic functionality to elixir
This commit is contained in:
188
README.md
188
README.md
@@ -1,18 +1,182 @@
|
|||||||
# ComponentsElixir
|
# Components Inventory - Elixir/Phoenix Implementation
|
||||||
|
|
||||||
To start your Phoenix server:
|
A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inventory management system.
|
||||||
|
|
||||||
* Run `mix setup` to install and setup dependencies
|
## Features
|
||||||
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
|
|
||||||
|
|
||||||
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
### ✨ Improvements over the original PHP version:
|
||||||
|
|
||||||
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
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
|
||||||
|
|
||||||
## Learn more
|
2. **Enhanced User Experience**
|
||||||
|
- Real-time search with no page refreshes
|
||||||
|
- Responsive design with Tailwind CSS
|
||||||
|
- Loading states and better feedback
|
||||||
|
- Improved mobile experience
|
||||||
|
|
||||||
* Official website: https://www.phoenixframework.org/
|
3. **Better Data Management**
|
||||||
* Guides: https://hexdocs.pm/phoenix/overview.html
|
- Full-text search with PostgreSQL
|
||||||
* Docs: https://hexdocs.pm/phoenix
|
- Hierarchical categories with parent-child relationships
|
||||||
* Forum: https://elixirforum.com/c/phoenix-forum
|
- Proper foreign key constraints
|
||||||
* Source: https://github.com/phoenixframework/phoenix
|
- 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
|
||||||
|
- **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
|
||||||
|
- **Datasheet Links**: Direct links to component datasheets
|
||||||
|
- **Position Tracking**: Track component storage locations
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
mix deps.get
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set up the database:**
|
||||||
|
```bash
|
||||||
|
mix ecto.create
|
||||||
|
mix ecto.migrate
|
||||||
|
mix run priv/repo/seeds.exs
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start the server:**
|
||||||
|
```bash
|
||||||
|
mix phx.server
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Visit the application:**
|
||||||
|
Open [http://localhost:4000](http://localhost:4000)
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The application uses a simple password-based authentication system:
|
||||||
|
- Default password: `changeme`
|
||||||
|
- Set custom password via environment variable: `AUTH_PASSWORD=your_password`
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
- `id`: Primary key
|
||||||
|
- `name`: Category name (required)
|
||||||
|
- `description`: Optional description
|
||||||
|
- `parent_id`: Foreign key for hierarchical categories
|
||||||
|
- Supports unlimited nesting levels
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Contexts
|
||||||
|
- **`ComponentsElixir.Inventory`**: Business logic for components and categories
|
||||||
|
- **`ComponentsElixir.Auth`**: Simple authentication system
|
||||||
|
|
||||||
|
### Live Views
|
||||||
|
- **`ComponentsElixirWeb.LoginLive`**: Authentication interface
|
||||||
|
- **`ComponentsElixirWeb.ComponentsLive`**: Main component management interface
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
## API Comparison
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| `changeAmount.php` | `Inventory.update_component_count/2` | Atomic operations, constraints |
|
||||||
|
| `imageUpload.php` | (Future: Phoenix file uploads) | Planned improvement |
|
||||||
|
| Session management | Phoenix sessions + LiveView | Built-in CSRF protection |
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Image Upload**: Implement Phoenix file uploads for component images
|
||||||
|
2. **Category Management UI**: Add/edit/delete categories through the web interface
|
||||||
|
3. **Bulk Operations**: Import/export components via CSV
|
||||||
|
4. **API Endpoints**: REST API for external integrations
|
||||||
|
5. **User Management**: Multi-user support with roles and permissions
|
||||||
|
6. **Advanced Search**: Filters by category, stock level, etc.
|
||||||
|
7. **Barcode/QR Codes**: Generate and scan codes for quick inventory updates
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
mix test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
```bash
|
||||||
|
mix format
|
||||||
|
mix credo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Management
|
||||||
|
```bash
|
||||||
|
# Reset database
|
||||||
|
mix ecto.reset
|
||||||
|
|
||||||
|
# Add new migration
|
||||||
|
mix ecto.gen.migration add_feature
|
||||||
|
|
||||||
|
# Check migration status
|
||||||
|
mix ecto.migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
For production deployment:
|
||||||
|
|
||||||
|
1. **Set environment variables:**
|
||||||
|
```bash
|
||||||
|
export AUTH_PASSWORD=your_secure_password
|
||||||
|
export SECRET_KEY_BASE=your_secret_key
|
||||||
|
export DATABASE_URL=postgresql://user:pass@host/db
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Build release:**
|
||||||
|
```bash
|
||||||
|
MIX_ENV=prod mix release
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run migrations:**
|
||||||
|
```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.
|
||||||
|
|
||||||
|
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
|
||||||
@@ -9,7 +9,8 @@ import Config
|
|||||||
|
|
||||||
config :components_elixir,
|
config :components_elixir,
|
||||||
ecto_repos: [ComponentsElixir.Repo],
|
ecto_repos: [ComponentsElixir.Repo],
|
||||||
generators: [timestamp_type: :utc_datetime]
|
generators: [timestamp_type: :utc_datetime],
|
||||||
|
auth_password: System.get_env("AUTH_PASSWORD", "changeme")
|
||||||
|
|
||||||
# Configures the endpoint
|
# Configures the endpoint
|
||||||
config :components_elixir, ComponentsElixirWeb.Endpoint,
|
config :components_elixir, ComponentsElixirWeb.Endpoint,
|
||||||
|
|||||||
67
lib/components_elixir/auth.ex
Normal file
67
lib/components_elixir/auth.ex
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
defmodule ComponentsElixir.Auth do
|
||||||
|
@moduledoc """
|
||||||
|
Simple authentication system for the components inventory.
|
||||||
|
|
||||||
|
Uses a configured password for authentication with session management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Validates the provided password against the configured password.
|
||||||
|
"""
|
||||||
|
def authenticate(password) do
|
||||||
|
configured_password = Application.get_env(:components_elixir, :auth_password, "changeme")
|
||||||
|
|
||||||
|
if password == configured_password do
|
||||||
|
{:ok, :authenticated}
|
||||||
|
else
|
||||||
|
{:error, :invalid_credentials}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks if the current session is authenticated.
|
||||||
|
"""
|
||||||
|
def authenticated?(conn_or_socket_or_session) do
|
||||||
|
case get_session_value(conn_or_socket_or_session, :authenticated) do
|
||||||
|
true -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Marks the session as authenticated.
|
||||||
|
"""
|
||||||
|
def sign_in(conn_or_socket) do
|
||||||
|
put_session_value(conn_or_socket, :authenticated, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Clears the authentication from the session.
|
||||||
|
"""
|
||||||
|
def sign_out(conn_or_socket) do
|
||||||
|
put_session_value(conn_or_socket, :authenticated, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper functions to handle both Plug.Conn and Phoenix.LiveView.Socket
|
||||||
|
defp get_session_value(%Plug.Conn{} = conn, key) do
|
||||||
|
Plug.Conn.get_session(conn, key)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_session_value(%Phoenix.LiveView.Socket{} = socket, key) do
|
||||||
|
get_in(socket.assigns, [:session, key])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_session_value(session, key) when is_map(session) do
|
||||||
|
# Handle both string and atom keys
|
||||||
|
Map.get(session, to_string(key)) || Map.get(session, key)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_session_value(%Plug.Conn{} = conn, key, value) do
|
||||||
|
Plug.Conn.put_session(conn, key, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_session_value(%Phoenix.LiveView.Socket{} = socket, key, value) do
|
||||||
|
session = Map.put(socket.assigns[:session] || %{}, key, value)
|
||||||
|
Phoenix.LiveView.assign(socket, session: session)
|
||||||
|
end
|
||||||
|
end
|
||||||
254
lib/components_elixir/inventory.ex
Normal file
254
lib/components_elixir/inventory.ex
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
defmodule ComponentsElixir.Inventory do
|
||||||
|
@moduledoc """
|
||||||
|
The Inventory context for managing components and categories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
alias ComponentsElixir.Repo
|
||||||
|
|
||||||
|
alias ComponentsElixir.Inventory.{Category, Component}
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of categories.
|
||||||
|
"""
|
||||||
|
def list_categories do
|
||||||
|
Category
|
||||||
|
|> order_by([c], [asc: c.name])
|
||||||
|
|> preload(:parent)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of root categories (no parent).
|
||||||
|
"""
|
||||||
|
def list_root_categories do
|
||||||
|
Category
|
||||||
|
|> where([c], is_nil(c.parent_id))
|
||||||
|
|> order_by([c], [asc: c.name])
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of child categories for a given parent.
|
||||||
|
"""
|
||||||
|
def list_child_categories(parent_id) do
|
||||||
|
Category
|
||||||
|
|> where([c], c.parent_id == ^parent_id)
|
||||||
|
|> order_by([c], [asc: c.name])
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single category.
|
||||||
|
"""
|
||||||
|
def get_category!(id) do
|
||||||
|
Category
|
||||||
|
|> preload(:parent)
|
||||||
|
|> Repo.get!(id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a category.
|
||||||
|
"""
|
||||||
|
def create_category(attrs \\ %{}) do
|
||||||
|
%Category{}
|
||||||
|
|> Category.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a category.
|
||||||
|
"""
|
||||||
|
def update_category(%Category{} = category, attrs) do
|
||||||
|
category
|
||||||
|
|> Category.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a category.
|
||||||
|
"""
|
||||||
|
def delete_category(%Category{} = category) do
|
||||||
|
Repo.delete(category)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns an `%Ecto.Changeset{}` for tracking category changes.
|
||||||
|
"""
|
||||||
|
def change_category(%Category{} = category, attrs \\ %{}) do
|
||||||
|
Category.changeset(category, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of components with optional filtering and pagination.
|
||||||
|
"""
|
||||||
|
def list_components(opts \\ []) do
|
||||||
|
Component
|
||||||
|
|> apply_component_filters(opts)
|
||||||
|
|> preload(:category)
|
||||||
|
|> order_by([c], [asc: c.category_id, asc: c.name])
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns paginated components with search and filtering.
|
||||||
|
"""
|
||||||
|
def paginate_components(opts \\ []) do
|
||||||
|
limit = Keyword.get(opts, :limit, 20)
|
||||||
|
offset = Keyword.get(opts, :offset, 0)
|
||||||
|
|
||||||
|
query =
|
||||||
|
Component
|
||||||
|
|> apply_component_filters(opts)
|
||||||
|
|> preload(:category)
|
||||||
|
|> order_by([c], [asc: c.category_id, asc: c.name])
|
||||||
|
|
||||||
|
components =
|
||||||
|
query
|
||||||
|
|> limit(^limit)
|
||||||
|
|> offset(^offset)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
total_count = Repo.aggregate(query, :count, :id)
|
||||||
|
|
||||||
|
%{
|
||||||
|
components: components,
|
||||||
|
total_count: total_count,
|
||||||
|
has_more: total_count > offset + length(components)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_component_filters(query, opts) do
|
||||||
|
Enum.reduce(opts, query, fn
|
||||||
|
{:search, search}, query when is_binary(search) and search != "" ->
|
||||||
|
if String.length(search) > 3 do
|
||||||
|
# Use full-text search for longer queries
|
||||||
|
where(query, [c],
|
||||||
|
fragment("to_tsvector('english', ? || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) @@ plainto_tsquery(?)",
|
||||||
|
c.name, c.description, c.keywords, ^search))
|
||||||
|
else
|
||||||
|
# Use ILIKE for shorter queries
|
||||||
|
search_term = "%#{search}%"
|
||||||
|
where(query, [c],
|
||||||
|
ilike(c.name, ^search_term) or
|
||||||
|
ilike(c.description, ^search_term) or
|
||||||
|
ilike(c.keywords, ^search_term))
|
||||||
|
end
|
||||||
|
|
||||||
|
{:category_id, category_id}, query when is_integer(category_id) ->
|
||||||
|
where(query, [c], c.category_id == ^category_id)
|
||||||
|
|
||||||
|
{:sort_criteria, "name"}, query ->
|
||||||
|
order_by(query, [c], [asc: c.name])
|
||||||
|
|
||||||
|
{:sort_criteria, "description"}, query ->
|
||||||
|
order_by(query, [c], [asc: c.description])
|
||||||
|
|
||||||
|
{:sort_criteria, "id"}, query ->
|
||||||
|
order_by(query, [c], [asc: c.id])
|
||||||
|
|
||||||
|
{:sort_criteria, "category_id"}, query ->
|
||||||
|
order_by(query, [c], [asc: c.category_id, asc: c.name])
|
||||||
|
|
||||||
|
_, query ->
|
||||||
|
query
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single component.
|
||||||
|
"""
|
||||||
|
def get_component!(id) do
|
||||||
|
Component
|
||||||
|
|> preload(:category)
|
||||||
|
|> Repo.get!(id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a component.
|
||||||
|
"""
|
||||||
|
def create_component(attrs \\ %{}) do
|
||||||
|
%Component{}
|
||||||
|
|> Component.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a component.
|
||||||
|
"""
|
||||||
|
def update_component(%Component{} = component, attrs) do
|
||||||
|
component
|
||||||
|
|> Component.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a component's count.
|
||||||
|
"""
|
||||||
|
def update_component_count(%Component{} = component, count) when is_integer(count) do
|
||||||
|
component
|
||||||
|
|> Component.count_changeset(%{count: count})
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Increments a component's count.
|
||||||
|
"""
|
||||||
|
def increment_component_count(%Component{} = component) do
|
||||||
|
update_component_count(component, component.count + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Decrements a component's count (minimum 0).
|
||||||
|
"""
|
||||||
|
def decrement_component_count(%Component{} = component) do
|
||||||
|
new_count = max(0, component.count - 1)
|
||||||
|
update_component_count(component, new_count)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a component's image filename.
|
||||||
|
"""
|
||||||
|
def update_component_image(%Component{} = component, image_filename) do
|
||||||
|
component
|
||||||
|
|> Component.image_changeset(%{image_filename: image_filename})
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a component.
|
||||||
|
"""
|
||||||
|
def delete_component(%Component{} = component) do
|
||||||
|
Repo.delete(component)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns an `%Ecto.Changeset{}` for tracking component changes.
|
||||||
|
"""
|
||||||
|
def change_component(%Component{} = component, attrs \\ %{}) do
|
||||||
|
Component.changeset(component, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns component statistics.
|
||||||
|
"""
|
||||||
|
def component_stats do
|
||||||
|
total_components = Repo.aggregate(Component, :count, :id)
|
||||||
|
total_stock = Repo.aggregate(Component, :sum, :count) || 0
|
||||||
|
categories_with_components =
|
||||||
|
Component
|
||||||
|
|> select([c], c.category_id)
|
||||||
|
|> distinct(true)
|
||||||
|
|> Repo.aggregate(:count, :category_id)
|
||||||
|
|
||||||
|
%{
|
||||||
|
total_components: total_components,
|
||||||
|
total_stock: total_stock,
|
||||||
|
categories_with_components: categories_with_components
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
44
lib/components_elixir/inventory/category.ex
Normal file
44
lib/components_elixir/inventory/category.ex
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
defmodule ComponentsElixir.Inventory.Category do
|
||||||
|
@moduledoc """
|
||||||
|
Schema for component categories.
|
||||||
|
|
||||||
|
Categories can be hierarchical with parent-child relationships.
|
||||||
|
"""
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias ComponentsElixir.Inventory.{Category, Component}
|
||||||
|
|
||||||
|
schema "categories" do
|
||||||
|
field :name, :string
|
||||||
|
field :description, :string
|
||||||
|
|
||||||
|
belongs_to :parent, Category
|
||||||
|
has_many :children, Category, foreign_key: :parent_id
|
||||||
|
has_many :components, Component
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(category, attrs) do
|
||||||
|
category
|
||||||
|
|> cast(attrs, [:name, :description, :parent_id])
|
||||||
|
|> validate_required([:name])
|
||||||
|
|> validate_length(:name, min: 1, max: 255)
|
||||||
|
|> validate_length(:description, max: 1000)
|
||||||
|
|> unique_constraint([:name, :parent_id])
|
||||||
|
|> foreign_key_constraint(:parent_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the full path of the category including parent names.
|
||||||
|
"""
|
||||||
|
def full_path(%Category{parent: nil} = category), do: category.name
|
||||||
|
def full_path(%Category{parent: %Category{} = parent} = category) do
|
||||||
|
"#{full_path(parent)} > #{category.name}"
|
||||||
|
end
|
||||||
|
def full_path(%Category{parent: %Ecto.Association.NotLoaded{}} = category) do
|
||||||
|
category.name
|
||||||
|
end
|
||||||
|
end
|
||||||
86
lib/components_elixir/inventory/component.ex
Normal file
86
lib/components_elixir/inventory/component.ex
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
defmodule ComponentsElixir.Inventory.Component do
|
||||||
|
@moduledoc """
|
||||||
|
Schema for electronic components.
|
||||||
|
|
||||||
|
Each component belongs to a category and tracks inventory information
|
||||||
|
including count, location, and optional image and datasheet.
|
||||||
|
"""
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias ComponentsElixir.Inventory.Category
|
||||||
|
|
||||||
|
schema "components" do
|
||||||
|
field :name, :string
|
||||||
|
field :description, :string
|
||||||
|
field :keywords, :string
|
||||||
|
field :position, :string
|
||||||
|
field :count, :integer, default: 0
|
||||||
|
field :datasheet_url, :string
|
||||||
|
field :image_filename, :string
|
||||||
|
|
||||||
|
belongs_to :category, Category
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(component, attrs) do
|
||||||
|
component
|
||||||
|
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id])
|
||||||
|
|> validate_required([:name, :category_id])
|
||||||
|
|> validate_length(:name, min: 1, max: 255)
|
||||||
|
|> validate_length(:description, max: 2000)
|
||||||
|
|> validate_length(:keywords, max: 500)
|
||||||
|
|> validate_length(:position, max: 100)
|
||||||
|
|> validate_number(:count, greater_than_or_equal_to: 0)
|
||||||
|
|> validate_url(:datasheet_url)
|
||||||
|
|> foreign_key_constraint(:category_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Changeset for updating component count.
|
||||||
|
"""
|
||||||
|
def count_changeset(component, attrs) do
|
||||||
|
component
|
||||||
|
|> cast(attrs, [:count])
|
||||||
|
|> validate_required([:count])
|
||||||
|
|> validate_number(:count, greater_than_or_equal_to: 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Changeset for updating component image.
|
||||||
|
"""
|
||||||
|
def image_changeset(component, attrs) do
|
||||||
|
component
|
||||||
|
|> cast(attrs, [:image_filename])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_url(changeset, field) do
|
||||||
|
validate_change(changeset, field, fn ^field, url ->
|
||||||
|
if url && url != "" do
|
||||||
|
case URI.parse(url) do
|
||||||
|
%URI{scheme: scheme} when scheme in ["http", "https"] -> []
|
||||||
|
_ -> [{field, "must be a valid URL"}]
|
||||||
|
end
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if the component has an image.
|
||||||
|
"""
|
||||||
|
def has_image?(%__MODULE__{image_filename: filename}) when is_binary(filename), do: true
|
||||||
|
def has_image?(_), do: false
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the search text for this component.
|
||||||
|
"""
|
||||||
|
def search_text(%__MODULE__{} = component) do
|
||||||
|
[component.name, component.description, component.keywords]
|
||||||
|
|> Enum.filter(&is_binary/1)
|
||||||
|
|> Enum.join(" ")
|
||||||
|
end
|
||||||
|
end
|
||||||
27
lib/components_elixir_web/controllers/auth_controller.ex
Normal file
27
lib/components_elixir_web/controllers/auth_controller.ex
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
defmodule ComponentsElixirWeb.AuthController do
|
||||||
|
use ComponentsElixirWeb, :controller
|
||||||
|
|
||||||
|
alias ComponentsElixir.Auth
|
||||||
|
|
||||||
|
def authenticate(conn, %{"password" => password}) do
|
||||||
|
case Auth.authenticate(password) do
|
||||||
|
{:ok, :authenticated} ->
|
||||||
|
conn
|
||||||
|
|> Auth.sign_in()
|
||||||
|
|> put_flash(:info, "Successfully logged in!")
|
||||||
|
|> redirect(to: ~p"/")
|
||||||
|
|
||||||
|
{:error, :invalid_credentials} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Invalid password")
|
||||||
|
|> redirect(to: ~p"/login")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def logout(conn, _params) do
|
||||||
|
conn
|
||||||
|
|> Auth.sign_out()
|
||||||
|
|> put_flash(:info, "Logged out successfully")
|
||||||
|
|> redirect(to: ~p"/login")
|
||||||
|
end
|
||||||
|
end
|
||||||
454
lib/components_elixir_web/live/components_live.ex
Normal file
454
lib/components_elixir_web/live/components_live.ex
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
defmodule ComponentsElixirWeb.ComponentsLive do
|
||||||
|
use ComponentsElixirWeb, :live_view
|
||||||
|
|
||||||
|
alias ComponentsElixir.{Inventory, Auth}
|
||||||
|
alias ComponentsElixir.Inventory.Component
|
||||||
|
|
||||||
|
@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
|
||||||
|
categories = Inventory.list_categories()
|
||||||
|
stats = Inventory.component_stats()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:session, session)
|
||||||
|
|> assign(:categories, categories)
|
||||||
|
|> assign(:stats, stats)
|
||||||
|
|> assign(:search, "")
|
||||||
|
|> assign(:sort_criteria, "all_not_id")
|
||||||
|
|> assign(:selected_category, nil)
|
||||||
|
|> assign(:offset, 0)
|
||||||
|
|> assign(:components, [])
|
||||||
|
|> assign(:has_more, false)
|
||||||
|
|> assign(:loading, false)
|
||||||
|
|> assign(:show_add_form, false)
|
||||||
|
|> assign(:form, nil)
|
||||||
|
|> load_components()}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_params(params, _uri, socket) do
|
||||||
|
search = Map.get(params, "search", "")
|
||||||
|
criteria = Map.get(params, "criteria", "all_not_id")
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:search, search)
|
||||||
|
|> assign(:sort_criteria, criteria)
|
||||||
|
|> assign(:offset, 0)
|
||||||
|
|> load_components()}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("search", %{"search" => search}, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:search, search)
|
||||||
|
|> assign(:offset, 0)
|
||||||
|
|> load_components()
|
||||||
|
|> push_patch(to: ~p"/?#{build_query_params(socket, %{search: search})}")}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("sort_change", %{"sort_criteria" => criteria}, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:sort_criteria, criteria)
|
||||||
|
|> assign(:offset, 0)
|
||||||
|
|> load_components()
|
||||||
|
|> push_patch(to: ~p"/?#{build_query_params(socket, %{criteria: criteria})}")}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("load_more", _params, socket) do
|
||||||
|
new_offset = socket.assigns.offset + @items_per_page
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:offset, new_offset)
|
||||||
|
|> load_components(append: true)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("increment_count", %{"id" => id}, socket) do
|
||||||
|
component = Inventory.get_component!(id)
|
||||||
|
|
||||||
|
case Inventory.increment_component_count(component) do
|
||||||
|
{:ok, _updated_component} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Count updated")
|
||||||
|
|> load_components()}
|
||||||
|
|
||||||
|
{:error, _changeset} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Failed to update count")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("decrement_count", %{"id" => id}, socket) do
|
||||||
|
component = Inventory.get_component!(id)
|
||||||
|
|
||||||
|
case Inventory.decrement_component_count(component) do
|
||||||
|
{:ok, _updated_component} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Count updated")
|
||||||
|
|> load_components()}
|
||||||
|
|
||||||
|
{:error, _changeset} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Failed to update count")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("delete_component", %{"id" => id}, socket) do
|
||||||
|
component = Inventory.get_component!(id)
|
||||||
|
|
||||||
|
case Inventory.delete_component(component) do
|
||||||
|
{:ok, _deleted_component} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Component deleted")
|
||||||
|
|> load_components()}
|
||||||
|
|
||||||
|
{:error, _changeset} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Failed to delete component")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("show_add_form", _params, socket) do
|
||||||
|
changeset = Inventory.change_component(%Component{})
|
||||||
|
form = to_form(changeset)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:show_add_form, true)
|
||||||
|
|> assign(:form, form)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("hide_add_form", _params, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:show_add_form, false)
|
||||||
|
|> assign(:form, nil)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("save_component", %{"component" => component_params}, socket) do
|
||||||
|
case Inventory.create_component(component_params) do
|
||||||
|
{:ok, _component} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Component created successfully")
|
||||||
|
|> assign(:show_add_form, false)
|
||||||
|
|> assign(:form, nil)
|
||||||
|
|> load_components()}
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_components(socket, opts \\ []) do
|
||||||
|
append = Keyword.get(opts, :append, false)
|
||||||
|
|
||||||
|
filters = [
|
||||||
|
search: socket.assigns.search,
|
||||||
|
sort_criteria: socket.assigns.sort_criteria,
|
||||||
|
limit: @items_per_page,
|
||||||
|
offset: socket.assigns.offset
|
||||||
|
]
|
||||||
|
|
||||||
|
%{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 build_query_params(socket, overrides) do
|
||||||
|
params = %{
|
||||||
|
search: Map.get(overrides, :search, socket.assigns.search),
|
||||||
|
criteria: Map.get(overrides, :criteria, socket.assigns.sort_criteria)
|
||||||
|
}
|
||||||
|
|
||||||
|
params
|
||||||
|
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
|
||||||
|
|> URI.encode_query()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp category_options(categories) do
|
||||||
|
[{"Select a category", nil}] ++
|
||||||
|
Enum.map(categories, fn category ->
|
||||||
|
{category.name, category.id}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white shadow">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center py-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">
|
||||||
|
Components Inventory
|
||||||
|
</h1>
|
||||||
|
<div class="ml-8 text-sm text-gray-500">
|
||||||
|
<%= @stats.total_components %> components • <%= @stats.total_stock %> items in stock
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
phx-click="show_add_form"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<.icon name="hero-plus" class="w-4 h-4 mr-2" />
|
||||||
|
Add Component
|
||||||
|
</button>
|
||||||
|
<.link
|
||||||
|
href="/logout"
|
||||||
|
method="post"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<.icon name="hero-arrow-right-on-rectangle" class="w-4 h-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<div class="flex-1">
|
||||||
|
<form phx-change="search" phx-submit="search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="search"
|
||||||
|
value={@search}
|
||||||
|
placeholder="Search components..."
|
||||||
|
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<form phx-change="sort_change">
|
||||||
|
<select
|
||||||
|
name="sort_criteria"
|
||||||
|
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="all_not_id" selected={@sort_criteria == "all_not_id"}>All (without IDs)</option>
|
||||||
|
<option value="all" selected={@sort_criteria == "all"}>All</option>
|
||||||
|
<option value="name" selected={@sort_criteria == "name"}>Name</option>
|
||||||
|
<option value="description" selected={@sort_criteria == "description"}>Description</option>
|
||||||
|
<option value="id" selected={@sort_criteria == "id"}>ID</option>
|
||||||
|
<option value="category_id" selected={@sort_criteria == "category_id"}>Category ID</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Component Modal -->
|
||||||
|
<%= if @show_add_form do %>
|
||||||
|
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 shadow-lg rounded-md bg-white">
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Add New Component</h3>
|
||||||
|
<button
|
||||||
|
phx-click="hide_add_form"
|
||||||
|
class="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.form for={@form} phx-submit="save_component" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||||
|
<.input field={@form[:name]} type="text" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Category</label>
|
||||||
|
<.input field={@form[:category_id]} type="select" options={category_options(@categories)} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<.input field={@form[:description]} type="textarea" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Keywords</label>
|
||||||
|
<.input field={@form[:keywords]} type="text" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Position</label>
|
||||||
|
<.input field={@form[:position]} type="text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Count</label>
|
||||||
|
<.input field={@form[:count]} type="number" min="0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Datasheet URL</label>
|
||||||
|
<.input field={@form[:datasheet_url]} type="url" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="hide_add_form"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Save Component
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
</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-white shadow overflow-hidden sm:rounded-md">
|
||||||
|
<ul class="divide-y divide-gray-200" id="components-list" phx-update="replace">
|
||||||
|
<%= for component <- @components do %>
|
||||||
|
<li id={"component-#{component.id}"} class="px-6 py-4 hover:bg-gray-50">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-medium text-indigo-600 truncate">
|
||||||
|
<%= if component.datasheet_url do %>
|
||||||
|
<a href={component.datasheet_url} target="_blank" class="hover:text-indigo-500">
|
||||||
|
<%= component.name %>
|
||||||
|
<.icon name="hero-arrow-top-right-on-square" class="w-4 h-4 inline ml-1" />
|
||||||
|
</a>
|
||||||
|
<% else %>
|
||||||
|
<%= component.name %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<div class="ml-2 flex-shrink-0 flex">
|
||||||
|
<p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||||
|
<%= component.category.name %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex justify-between">
|
||||||
|
<div class="sm:flex">
|
||||||
|
<p class="flex items-center text-sm text-gray-500">
|
||||||
|
<%= if component.description do %>
|
||||||
|
<%= component.description %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
|
||||||
|
<%= if component.position do %>
|
||||||
|
<p class="mr-6">
|
||||||
|
<span class="font-medium">Position:</span> <%= component.position %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<p class="mr-6">
|
||||||
|
<span class="font-medium">Count:</span> <%= component.count %>
|
||||||
|
</p>
|
||||||
|
<%= if @sort_criteria == "all" or @sort_criteria == "id" do %>
|
||||||
|
<p class="mr-6">
|
||||||
|
<span class="font-medium">ID:</span> <%= component.id %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= if component.keywords do %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
<span class="font-medium">Keywords:</span> <%= component.keywords %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 flex-shrink-0 flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
phx-click="increment_count"
|
||||||
|
phx-value-id={component.id}
|
||||||
|
class="inline-flex items-center p-1 border border-transparent rounded-full shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
<.icon name="hero-plus" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
phx-click="decrement_count"
|
||||||
|
phx-value-id={component.id}
|
||||||
|
class="inline-flex items-center p-1 border border-transparent rounded-full shadow-sm text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500"
|
||||||
|
>
|
||||||
|
<.icon name="hero-minus" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
phx-click="delete_component"
|
||||||
|
phx-value-id={component.id}
|
||||||
|
data-confirm="Are you sure you want to delete this component?"
|
||||||
|
class="inline-flex items-center p-1 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<%= if @has_more do %>
|
||||||
|
<div class="bg-white px-4 py-3 flex items-center justify-center border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
phx-click="load_more"
|
||||||
|
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if Enum.empty?(@components) do %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<.icon name="hero-cube-transparent" class="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No components found</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
<%= if @search != "" do %>
|
||||||
|
Try adjusting your search terms.
|
||||||
|
<% else %>
|
||||||
|
Get started by adding your first component.
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
86
lib/components_elixir_web/live/login_live.ex
Normal file
86
lib/components_elixir_web/live/login_live.ex
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
defmodule ComponentsElixirWeb.LoginLive do
|
||||||
|
use ComponentsElixirWeb, :live_view
|
||||||
|
|
||||||
|
alias ComponentsElixir.Auth
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, session, socket) do
|
||||||
|
# If already authenticated, redirect to components
|
||||||
|
if Map.get(session, "authenticated") do
|
||||||
|
{:ok, socket |> push_navigate(to: ~p"/")}
|
||||||
|
else
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:session, session)
|
||||||
|
|> assign(:error_message, nil)
|
||||||
|
|> assign(:form, to_form(%{"password" => ""}))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("login", %{"password" => password}, socket) do
|
||||||
|
case Auth.authenticate(password) do
|
||||||
|
{:ok, :authenticated} ->
|
||||||
|
# Store authentication in a cookie that the server can read
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Successfully logged in!")
|
||||||
|
|> push_navigate(to: "/login/authenticate?password=#{URI.encode(password)}")}
|
||||||
|
|
||||||
|
{:error, :invalid_credentials} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:error_message, "Invalid password")
|
||||||
|
|> assign(:form, to_form(%{"password" => ""}))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Components Inventory
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Please enter your password to continue
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.form for={@form} phx-submit="login" class="mt-8 space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="password" class="sr-only">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
value={@form[:password].value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @error_message do %>
|
||||||
|
<div class="text-red-600 text-sm text-center">
|
||||||
|
<%= @error_message %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
22
lib/components_elixir_web/plugs/auth_plug.ex
Normal file
22
lib/components_elixir_web/plugs/auth_plug.ex
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
defmodule ComponentsElixirWeb.AuthPlug do
|
||||||
|
@moduledoc """
|
||||||
|
Plug for handling authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
|
||||||
|
alias ComponentsElixir.Auth
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
if Auth.authenticated?(conn) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> redirect(to: "/login")
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,6 +10,10 @@ defmodule ComponentsElixirWeb.Router do
|
|||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
end
|
end
|
||||||
|
|
||||||
|
pipeline :authenticated do
|
||||||
|
plug ComponentsElixirWeb.AuthPlug
|
||||||
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
plug :accepts, ["json"]
|
plug :accepts, ["json"]
|
||||||
end
|
end
|
||||||
@@ -17,7 +21,15 @@ defmodule ComponentsElixirWeb.Router do
|
|||||||
scope "/", ComponentsElixirWeb do
|
scope "/", ComponentsElixirWeb do
|
||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
get "/", PageController, :home
|
live "/login", LoginLive, :index
|
||||||
|
get "/login/authenticate", AuthController, :authenticate
|
||||||
|
post "/logout", AuthController, :logout
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", ComponentsElixirWeb do
|
||||||
|
pipe_through [:browser, :authenticated]
|
||||||
|
|
||||||
|
live "/", ComponentsLive, :index
|
||||||
end
|
end
|
||||||
|
|
||||||
# Other scopes may use custom stacks.
|
# Other scopes may use custom stacks.
|
||||||
|
|||||||
16
priv/repo/migrations/20250913202135_create_categories.exs
Normal file
16
priv/repo/migrations/20250913202135_create_categories.exs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
defmodule ComponentsElixir.Repo.Migrations.CreateCategories do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:categories) do
|
||||||
|
add :name, :string, null: false
|
||||||
|
add :description, :text
|
||||||
|
add :parent_id, references(:categories, on_delete: :delete_all)
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:categories, [:parent_id])
|
||||||
|
create unique_index(:categories, [:name, :parent_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
25
priv/repo/migrations/20250913202150_create_components.exs
Normal file
25
priv/repo/migrations/20250913202150_create_components.exs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
defmodule ComponentsElixir.Repo.Migrations.CreateComponents do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:components) do
|
||||||
|
add :name, :string, null: false
|
||||||
|
add :description, :text
|
||||||
|
add :keywords, :string
|
||||||
|
add :position, :string
|
||||||
|
add :count, :integer, default: 0
|
||||||
|
add :datasheet_url, :string
|
||||||
|
add :image_filename, :string
|
||||||
|
add :category_id, references(:categories, on_delete: :restrict), null: false
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:components, [:category_id])
|
||||||
|
create index(:components, [:name])
|
||||||
|
|
||||||
|
# Full-text search indexes for PostgreSQL
|
||||||
|
execute "CREATE INDEX components_search_idx ON components USING gin(to_tsvector('english', name || ' ' || coalesce(description, '') || ' ' || coalesce(keywords, '')))",
|
||||||
|
"DROP INDEX components_search_idx"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,3 +9,136 @@
|
|||||||
#
|
#
|
||||||
# We recommend using the bang functions (`insert!`, `update!`
|
# We recommend using the bang functions (`insert!`, `update!`
|
||||||
# and so on) as they will fail if something goes wrong.
|
# and so on) as they will fail if something goes wrong.
|
||||||
|
|
||||||
|
alias ComponentsElixir.{Repo, Inventory}
|
||||||
|
alias ComponentsElixir.Inventory.{Category, Component}
|
||||||
|
|
||||||
|
# Clear existing data
|
||||||
|
Repo.delete_all(Component)
|
||||||
|
Repo.delete_all(Category)
|
||||||
|
|
||||||
|
# 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"})
|
||||||
|
|
||||||
|
# Create subcategories
|
||||||
|
{: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, _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
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create sample components
|
||||||
|
sample_components = [
|
||||||
|
%{
|
||||||
|
name: "1kΩ Resistor (1/4W)",
|
||||||
|
description: "Carbon film resistor, 5% tolerance",
|
||||||
|
keywords: "resistor carbon film 1k ohm",
|
||||||
|
position: "A1-1",
|
||||||
|
count: 150,
|
||||||
|
category_id: resistors.id
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "10kΩ Resistor (1/4W)",
|
||||||
|
description: "Carbon film resistor, 5% tolerance",
|
||||||
|
keywords: "resistor carbon film 10k ohm",
|
||||||
|
position: "A1-2",
|
||||||
|
count: 200,
|
||||||
|
category_id: resistors.id
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "100μF Electrolytic Capacitor",
|
||||||
|
description: "25V electrolytic capacitor, radial leads",
|
||||||
|
keywords: "capacitor electrolytic 100uf microfarad",
|
||||||
|
position: "B2-1",
|
||||||
|
count: 50,
|
||||||
|
category_id: capacitors.id
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "0.1μF Ceramic Capacitor",
|
||||||
|
description: "50V ceramic disc capacitor",
|
||||||
|
keywords: "capacitor ceramic 100nf nanofarad disc",
|
||||||
|
position: "B2-2",
|
||||||
|
count: 300,
|
||||||
|
category_id: capacitors.id
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "ATmega328P-PU",
|
||||||
|
description: "8-bit AVR microcontroller, DIP-28 package",
|
||||||
|
keywords: "microcontroller avr atmega328 arduino",
|
||||||
|
position: "C3-1",
|
||||||
|
count: 10,
|
||||||
|
datasheet_url: "https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf",
|
||||||
|
category_id: semiconductors.id
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "LM358 Op-Amp",
|
||||||
|
description: "Dual operational amplifier, DIP-8 package",
|
||||||
|
keywords: "opamp operational amplifier lm358 dual",
|
||||||
|
position: "C3-2",
|
||||||
|
count: 25,
|
||||||
|
category_id: semiconductors.id
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "2N2222 NPN Transistor",
|
||||||
|
description: "General purpose NPN transistor, TO-92 package",
|
||||||
|
keywords: "transistor npn 2n2222 to92",
|
||||||
|
position: "C3-3",
|
||||||
|
count: 40,
|
||||||
|
category_id: semiconductors.id
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "2.54mm Pin Headers",
|
||||||
|
description: "Male pin headers, 40 pins, break-away",
|
||||||
|
keywords: "header pins male 2.54mm breakaway",
|
||||||
|
position: "D4-1",
|
||||||
|
count: 20,
|
||||||
|
category_id: connectors.id
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "JST-XH 2-pin Connector",
|
||||||
|
description: "2-pin JST-XH connector with housing",
|
||||||
|
keywords: "jst xh connector 2pin housing",
|
||||||
|
position: "D4-2",
|
||||||
|
count: 30,
|
||||||
|
category_id: connectors.id
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "470Ω Resistor (1/4W)",
|
||||||
|
description: "Carbon film resistor, 5% tolerance, commonly used for LEDs",
|
||||||
|
keywords: "resistor carbon film 470 ohm led current limiting",
|
||||||
|
position: "A1-3",
|
||||||
|
count: 100,
|
||||||
|
category_id: resistors.id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.each(sample_components, fn component_attrs ->
|
||||||
|
{:ok, _component} = Inventory.create_component(component_attrs)
|
||||||
|
end)
|
||||||
|
|
||||||
|
IO.puts("Seeded database with categories and sample components!")
|
||||||
|
IO.puts("Categories: #{length(Inventory.list_categories())}")
|
||||||
|
IO.puts("Components: #{length(Inventory.list_components())}")
|
||||||
|
IO.puts("")
|
||||||
|
IO.puts("Login with password: changeme (or set AUTH_PASSWORD environment variable)")
|
||||||
|
|||||||
Reference in New Issue
Block a user