defmodule ComponentsElixirWeb.ComponentsLive do use ComponentsElixirWeb, :live_view alias ComponentsElixir.{Inventory, Auth} alias ComponentsElixir.Inventory.{Component, Category, 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 categories = Inventory.list_categories() storage_locations = Inventory.list_storage_locations() stats = Inventory.component_stats() {:ok, socket |> assign(:session, session) |> assign(:categories, categories) |> assign(:storage_locations, storage_locations) |> assign(:stats, stats) |> assign(:search, "") |> assign(:sort_criteria, "name_asc") |> assign(:selected_category, nil) |> assign(:offset, 0) |> assign(:components, []) |> assign(:has_more, false) |> assign(:loading, false) |> assign(:sort_freeze_until, nil) |> assign(:interacting_with, nil) |> assign(:sort_freeze_timer, nil) |> assign(:sort_frozen, false) |> assign(:show_add_form, false) |> assign(:show_edit_form, false) |> assign(:editing_component, nil) |> assign(:form, nil) |> assign(:show_image_modal, false) |> assign(:modal_image_url, nil) |> assign(:focused_component_id, nil) |> allow_upload(:image, accept: ~w(.jpg .jpeg .png .gif), max_entries: 1, max_file_size: 5_000_000 ) |> load_components()} end end @impl true def handle_params(params, _uri, socket) do search = Map.get(params, "search", "") criteria = Map.get(params, "criteria", "name_asc") {: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 query_string = build_query_params(socket, %{search: search}) path = if query_string == "", do: "/", else: "/?" <> query_string {:noreply, socket |> assign(:search, search) |> assign(:offset, 0) |> load_components() |> push_patch(to: path)} end def handle_event("sort_change", %{"sort_criteria" => criteria}, socket) do query_string = build_query_params(socket, %{criteria: criteria}) path = if query_string == "", do: "/", else: "/?" <> query_string {:noreply, socket |> assign(:sort_criteria, criteria) |> assign(:offset, 0) |> load_components() |> push_patch(to: path)} end def handle_event("category_filter", %{"category_id" => ""}, socket) do {:noreply, socket |> assign(:selected_category, nil) |> assign(:offset, 0) |> load_components()} end def handle_event("category_filter", %{"category_id" => category_id}, socket) do category_id = String.to_integer(category_id) {:noreply, socket |> assign(:selected_category, category_id) |> assign(:offset, 0) |> load_components()} 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} -> # Only apply sort freeze for dynamic sorting criteria should_freeze = socket.assigns.sort_criteria in [ "count_asc", "count_desc", "updated_at_asc", "updated_at_desc" ] if should_freeze do # Cancel any existing timer if socket.assigns.sort_freeze_timer do Process.cancel_timer(socket.assigns.sort_freeze_timer) end # Set sort freeze for 3 seconds and mark component as interacting freeze_until = DateTime.add(DateTime.utc_now(), 3, :second) # Set new timer to clear interaction state timer_ref = Process.send_after(self(), {:clear_interaction, id}, 3000) {:noreply, socket |> put_flash(:info, "Count updated") |> assign(:sort_freeze_until, freeze_until) |> assign(:interacting_with, id) |> assign(:sort_freeze_timer, timer_ref) |> assign(:sort_frozen, true) |> load_components()} else # Normal behavior for stable sorts {:noreply, socket |> put_flash(:info, "Count updated") |> load_components()} end {: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} -> # Only apply sort freeze for dynamic sorting criteria should_freeze = socket.assigns.sort_criteria in [ "count_asc", "count_desc", "updated_at_asc", "updated_at_desc" ] if should_freeze do # Cancel any existing timer if socket.assigns.sort_freeze_timer do Process.cancel_timer(socket.assigns.sort_freeze_timer) end # Set sort freeze for 3 seconds and mark component as interacting freeze_until = DateTime.add(DateTime.utc_now(), 3, :second) # Set new timer to clear interaction state timer_ref = Process.send_after(self(), {:clear_interaction, id}, 3000) {:noreply, socket |> put_flash(:info, "Count updated") |> assign(:sort_freeze_until, freeze_until) |> assign(:interacting_with, id) |> assign(:sort_freeze_timer, timer_ref) |> assign(:sort_frozen, true) |> load_components()} else # Normal behavior for stable sorts {:noreply, socket |> put_flash(:info, "Count updated") |> load_components()} end {: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} -> # Clean up the image file if it exists delete_image_file(component.image_filename) {: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("show_image", %{"url" => url}, socket) do {:noreply, socket |> assign(:show_image_modal, true) |> assign(:modal_image_url, url)} end def handle_event("close_image_modal", _params, socket) do {:noreply, socket |> assign(:show_image_modal, false) |> assign(:modal_image_url, nil)} end def handle_event("toggle_focus", %{"id" => id}, socket) do component_id = String.to_integer(id) new_focused_id = if socket.assigns.focused_component_id == component_id do # Unfocus if clicking on the same component nil else # Focus on the new component component_id end {:noreply, assign(socket, :focused_component_id, new_focused_id)} end def handle_event("prevent_close", _params, socket) do {:noreply, socket} end def handle_event("validate", _params, socket) do {:noreply, socket} end def handle_event("cancel-upload", %{"ref" => ref}, socket) do {:noreply, cancel_upload(socket, :image, ref)} end def handle_event("save_component", %{"component" => component_params}, socket) do # Handle any uploaded images updated_params = save_uploaded_image(socket, component_params) case Inventory.create_component(updated_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 # Handle any uploaded images updated_params = save_uploaded_image(socket, component_params) case Inventory.update_component(socket.assigns.editing_component, updated_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 @impl true def handle_info({:clear_interaction, component_id}, socket) do # Only clear if this timer is for the currently interacting component if socket.assigns.interacting_with == component_id do # Clear interaction state and allow sorting to resume {:noreply, socket |> assign(:sort_freeze_until, nil) |> assign(:interacting_with, nil) |> assign(:sort_freeze_timer, nil) |> assign(:sort_frozen, false) |> load_components()} else # Ignore stale timer messages {:noreply, socket} end end defp load_components(socket, opts \\ []) do append = Keyword.get(opts, :append, false) # Check if sorting should be frozen now = DateTime.utc_now() should_reload = is_nil(socket.assigns.sort_freeze_until) || DateTime.compare(now, socket.assigns.sort_freeze_until) != :lt if should_reload do # Normal loading - query database with current sort criteria filters = [ search: socket.assigns.search, sort_criteria: socket.assigns.sort_criteria, category_id: socket.assigns.selected_category, limit: @items_per_page, offset: socket.assigns.offset ] |> Enum.reject(fn {_, v} when is_nil(v) -> true {:search, v} when v == "" -> true {_, _} -> false end) %{components: new_components, has_more: has_more} = Inventory.paginate_components(filters) components = if append do socket.assigns.components ++ new_components else new_components end socket |> assign(:components, components) |> assign(:has_more, has_more) 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 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) } params |> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) |> URI.encode_query() end defp category_options(categories) do Hierarchical.select_options(categories, &(&1.parent), "Select a category") end defp storage_location_display_name(location) do StorageLocation.full_path(location) end defp storage_location_options(storage_locations) do Hierarchical.select_options(storage_locations, &(&1.parent), "No storage location") end @impl true def render(assigns) do ~H"""

Components Inventory

{@stats.total_components} components • {@stats.total_stock} items in stock
<.link navigate={~p"/categories"} class="inline-flex items-center px-4 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-folder" class="w-4 h-4 mr-2" /> Categories <.link navigate={~p"/storage_locations"} class="inline-flex items-center px-4 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-archive-box" class="w-4 h-4 mr-2" /> Storage <.link href="/logout" method="post" class="inline-flex items-center px-4 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-arrow-right-on-rectangle" class="w-4 h-4 mr-2" /> Logout
<%= if @sort_frozen do %>
Sort temporarily frozen
<% end %>
<%= if @show_add_form do %>

Add New Component

<.form for={@form} phx-submit="save_component" phx-change="validate" multipart={true} class="space-y-4" >
<.input field={@form[:name]} type="text" required />
<.input field={@form[:category_id]} type="select" options={category_options(@categories)} required />
<.input field={@form[:description]} type="textarea" />
<.input field={@form[:keywords]} type="text" />
<.input field={@form[:storage_location_id]} type="select" options={storage_location_options(@storage_locations)} />
<.input field={@form[:count]} type="number" min="0" />
<.input field={@form[:datasheet_url]} type="url" />
<.live_file_input upload={@uploads.image} class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20" />

JPG, PNG, GIF up to 5MB

<%= for err <- upload_errors(@uploads.image) do %>

{Phoenix.Naming.humanize(err)}

<% end %> <%= for entry <- @uploads.image.entries do %>
<.live_img_preview entry={entry} class="h-10 w-10 rounded-lg object-cover" />

{entry.client_name}

{entry.progress}%

<% end %> <%= for err <- upload_errors(@uploads.image) do %>

{upload_error_to_string(err)}

<% end %>
<% end %> <%= if @show_edit_form do %>

Edit Component

<.form for={@form} phx-submit="save_edit" phx-change="validate" multipart={true} class="space-y-4" >
<.input field={@form[:name]} type="text" required />
<.input field={@form[:category_id]} type="select" options={category_options(@categories)} required />
<.input field={@form[:description]} type="textarea" />
<.input field={@form[:keywords]} type="text" />
<.input field={@form[:storage_location_id]} type="select" options={storage_location_options(@storage_locations)} />
<.input field={@form[:count]} type="number" min="0" />
<.input field={@form[:datasheet_url]} type="url" />
<%= if @editing_component && @editing_component.image_filename do %>

Current image:

Current component
<% end %>
<.live_file_input upload={@uploads.image} class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20" />

JPG, PNG, GIF up to 5MB (leave empty to keep current image)

<%= for err <- upload_errors(@uploads.image) do %>

{Phoenix.Naming.humanize(err)}

<% end %> <%= for entry <- @uploads.image.entries do %>
<.live_img_preview entry={entry} class="h-10 w-10 rounded-lg object-cover" />

{entry.client_name}

{entry.progress}%

<% end %> <%= for err <- upload_errors(@uploads.image) do %>

{upload_error_to_string(err)}

<% end %>
<% end %>
    <%= for component <- @components do %>
  • <%= if @focused_component_id == component.id do %>

    <%= if component.datasheet_url do %> {component.name} <% else %> {component.name} <% end %>

    <%= if component.datasheet_url do %> 📄 <% end %>
    {component.category.name}
    <%= if component.image_filename do %> <% else %>
    <.icon name="hero-cube-transparent" class="h-20 w-20 text-base-content/50" />
    <% end %>
    <%= if component.description do %>

    Description

    {component.description}

    <% end %>
    <%= if component.storage_location do %>
    <.icon name="hero-map-pin" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0" />
    Location:
    {storage_location_display_name(component.storage_location)}
    <% end %>
    <.icon name="hero-cube" class="w-4 h-4 mr-2 text-base-content/50" /> Count: {component.count}
    <.icon name="hero-calendar" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0" />
    Entry Date:
    {Calendar.strftime(component.inserted_at, "%B %d, %Y")}
    <%= if @sort_criteria == "all" or @sort_criteria == "id" do %>
    <.icon name="hero-hashtag" class="w-4 h-4 mr-2 text-base-content/50" /> ID: {component.id}
    <% end %> <%= if component.keywords do %>
    <.icon name="hero-tag" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5" />
    Keywords:
    {component.keywords}
    <% end %>
    <% else %>
    <%= if component.image_filename do %> <% else %>
    <.icon name="hero-cube-transparent" class="h-10 w-10 text-base-content/50" />
    <% end %>

    <%= if component.datasheet_url do %> {component.name} <% else %> {component.name} <% end %>

    <%= if component.datasheet_url do %> 📄 <% end %>
    {component.category.name}
    <%= if component.description do %>

    {component.description}

    <% end %>
    <%= if component.storage_location do %>
    <.icon name="hero-map-pin" class="w-4 h-4 mr-1 text-base-content/50 flex-shrink-0" /> Location: {storage_location_display_name(component.storage_location)}
    <% end %>
    <.icon name="hero-cube" class="w-4 h-4 mr-1 text-base-content/50" /> Count: {component.count}
    <%= if @sort_criteria == "all" or @sort_criteria == "id" do %>
    <.icon name="hero-hashtag" class="w-4 h-4 mr-1 text-base-content/50" /> ID: {component.id}
    <% end %>
    <%= if component.keywords do %>

    Keywords: {component.keywords}

    <% end %>
    <% end %>
  • <% end %>
<%= if @has_more do %>
<% end %> <%= if Enum.empty?(@components) do %>
<.icon name="hero-cube-transparent" class="mx-auto h-12 w-12 text-base-content/50" />

No components found

<%= if @search != "" do %> Try adjusting your search terms. <% else %> Get started by adding your first component. <% end %>

<% end %>
<%= if @show_image_modal do %>

Component Image

<%= if @modal_image_url do %> Component image <% else %>

No image available

<% end %>
<% end %> """ end # 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}" uploads_dir = Application.get_env(:components_elixir, :uploads_dir) 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") 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) [] -> 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 end defp delete_image_file(nil), do: :ok defp delete_image_file(""), do: :ok defp delete_image_file(filename) do uploads_dir = Application.get_env(:components_elixir, :uploads_dir) path = Path.join([uploads_dir, "images", filename]) File.rm(path) end defp upload_error_to_string(:too_large), do: "File too large" defp upload_error_to_string(:too_many_files), do: "Too many files" defp upload_error_to_string(:not_accepted), do: "File type not accepted" defp upload_error_to_string(error), do: "Upload error: #{inspect(error)}" end