593 lines
22 KiB
Elixir
593 lines
22 KiB
Elixir
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(:show_edit_form, false)
|
|
|> assign(:editing_component, nil)
|
|
|> 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("show_edit_form", %{"id" => id}, socket) do
|
|
component = Inventory.get_component!(id)
|
|
changeset = Inventory.change_component(component)
|
|
form = to_form(changeset)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_edit_form, true)
|
|
|> assign(:editing_component, component)
|
|
|> assign(:form, form)}
|
|
end
|
|
|
|
def handle_event("hide_edit_form", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_edit_form, false)
|
|
|> assign(:editing_component, nil)
|
|
|> 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
|
|
|
|
def handle_event("save_edit", %{"component" => component_params}, socket) do
|
|
case Inventory.update_component(socket.assigns.editing_component, component_params) do
|
|
{:ok, _component} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "Component updated successfully")
|
|
|> assign(:show_edit_form, false)
|
|
|> assign(:editing_component, nil)
|
|
|> 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 %>
|
|
|
|
<!-- Edit Component Modal -->
|
|
<%= if @show_edit_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">Edit Component</h3>
|
|
<button
|
|
phx-click="hide_edit_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_edit" 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_edit_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"
|
|
>
|
|
Update 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="show_edit_form"
|
|
phx-value-id={component.id}
|
|
class="inline-flex items-center p-1 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
>
|
|
<.icon name="hero-pencil" 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
|