From 5a1775e836d5609f45adb49178cbf44646aa4115 Mon Sep 17 00:00:00 2001 From: Schuwi Date: Wed, 17 Sep 2025 23:13:45 +0200 Subject: [PATCH] refactor(elixir): remove unused is_active field from storage location --- lib/components_elixir/inventory.ex | 120 ++++--- .../inventory/storage_location.ex | 3 +- .../live/components_live.ex | 303 ++++++++++++------ ...emove_is_active_from_storage_locations.exs | 9 + 4 files changed, 286 insertions(+), 149 deletions(-) create mode 100644 priv/repo/migrations/20250917210658_remove_is_active_from_storage_locations.exs diff --git a/lib/components_elixir/inventory.ex b/lib/components_elixir/inventory.ex index 2bcb378..ae6d3c0 100644 --- a/lib/components_elixir/inventory.ex +++ b/lib/components_elixir/inventory.ex @@ -15,14 +15,16 @@ defmodule ComponentsElixir.Inventory do """ def list_storage_locations do # Get all locations with preloaded parents in a single query - locations = StorageLocation - |> order_by([sl], [asc: sl.name]) - |> preload(:parent) - |> Repo.all() + locations = + StorageLocation + |> order_by([sl], asc: sl.name) + |> preload(:parent) + |> Repo.all() # Compute hierarchy fields for all locations efficiently - processed_locations = compute_hierarchy_fields_batch(locations) - |> Enum.sort_by(&{&1.level, &1.name}) + processed_locations = + compute_hierarchy_fields_batch(locations) + |> Enum.sort_by(&{&1.level, &1.name}) # Ensure AprilTag SVGs exist for all locations spawn(fn -> @@ -46,24 +48,35 @@ defmodule ComponentsElixir.Inventory do end defp compute_level_efficient(%{parent_id: nil}, _location_map, _depth), do: 0 + defp compute_level_efficient(%{parent_id: parent_id}, location_map, depth) when depth < 10 do case Map.get(location_map, parent_id) do - nil -> 0 # Orphaned record + # Orphaned record + nil -> 0 parent -> 1 + compute_level_efficient(parent, location_map, depth + 1) end end - defp compute_level_efficient(_location, _location_map, _depth), do: 0 # Prevent infinite recursion + + # Prevent infinite recursion + defp compute_level_efficient(_location, _location_map, _depth), do: 0 defp compute_path_efficient(%{parent_id: nil, name: name}, _location_map, _depth), do: name - defp compute_path_efficient(%{parent_id: parent_id, name: name}, location_map, depth) when depth < 10 do + + defp compute_path_efficient(%{parent_id: parent_id, name: name}, location_map, depth) + when depth < 10 do case Map.get(location_map, parent_id) do - nil -> name # Orphaned record + # Orphaned record + nil -> + name + parent -> parent_path = compute_path_efficient(parent, location_map, depth + 1) "#{parent_path}/#{name}" end end - defp compute_path_efficient(%{name: name}, _location_map, _depth), do: name # Prevent infinite recursion + + # Prevent infinite recursion + defp compute_path_efficient(%{name: name}, _location_map, _depth), do: name @doc """ Returns the list of root storage locations (no parent). @@ -71,7 +84,7 @@ defmodule ComponentsElixir.Inventory do def list_root_storage_locations do StorageLocation |> where([sl], is_nil(sl.parent_id)) - |> order_by([sl], [asc: sl.name]) + |> order_by([sl], asc: sl.name) |> Repo.all() end @@ -79,9 +92,10 @@ defmodule ComponentsElixir.Inventory do Gets a single storage location with computed hierarchy fields. """ def get_storage_location!(id) do - location = StorageLocation - |> preload(:parent) - |> Repo.get!(id) + location = + StorageLocation + |> preload(:parent) + |> Repo.get!(id) # Compute hierarchy fields level = compute_level_for_single(location) @@ -91,6 +105,7 @@ defmodule ComponentsElixir.Inventory do # Simple computation for single location (allows DB queries) defp compute_level_for_single(%{parent_id: nil}), do: 0 + defp compute_level_for_single(%{parent_id: parent_id}) do case Repo.get(StorageLocation, parent_id) do nil -> 0 @@ -99,6 +114,7 @@ defmodule ComponentsElixir.Inventory do end defp compute_path_for_single(%{parent_id: nil, name: name}), do: name + defp compute_path_for_single(%{parent_id: parent_id, name: name}) do case Repo.get(StorageLocation, parent_id) do nil -> name @@ -115,7 +131,9 @@ defmodule ComponentsElixir.Inventory do |> preload(:parent) |> Repo.one() |> case do - nil -> nil + nil -> + nil + location -> level = compute_level_for_single(location) path = compute_path_for_single(location) @@ -130,13 +148,15 @@ defmodule ComponentsElixir.Inventory do # Convert string keys to atoms to maintain consistency attrs = normalize_string_keys(attrs) - result = %StorageLocation{} - |> StorageLocation.changeset(attrs) - |> Repo.insert() + result = + %StorageLocation{} + |> StorageLocation.changeset(attrs) + |> Repo.insert() case result do {:ok, location} -> {:ok, location} + error -> error end @@ -149,13 +169,15 @@ defmodule ComponentsElixir.Inventory do # Convert string keys to atoms to maintain consistency attrs = normalize_string_keys(attrs) - result = storage_location - |> StorageLocation.changeset(attrs) - |> Repo.update() + result = + storage_location + |> StorageLocation.changeset(attrs) + |> Repo.update() case result do {:ok, updated_location} -> {:ok, updated_location} + error -> error end @@ -182,12 +204,14 @@ defmodule ComponentsElixir.Inventory do case get_storage_location_by_apriltag_id(apriltag_id) do nil -> {:error, :not_found} + location -> - {:ok, %{ - type: :storage_location, - location: location, - apriltag_id: apriltag_id - }} + {:ok, + %{ + type: :storage_location, + location: location, + apriltag_id: apriltag_id + }} end end @@ -195,6 +219,7 @@ defmodule ComponentsElixir.Inventory do Computes the path for a storage location (for display purposes). """ def compute_storage_location_path(nil), do: nil + def compute_storage_location_path(%StorageLocation{} = location) do compute_path_for_single(location) end @@ -205,6 +230,7 @@ defmodule ComponentsElixir.Inventory do {key, value}, acc when is_binary(key) -> atom_key = String.to_atom(key) Map.put(acc, atom_key, value) + {key, value}, acc -> Map.put(acc, key, value) end) @@ -281,28 +307,33 @@ defmodule ComponentsElixir.Inventory do {:search, search_term}, query when is_binary(search_term) and search_term != "" -> search_pattern = "%#{search_term}%" - where(query, [c], + + where( + query, + [c], ilike(c.name, ^search_pattern) or - ilike(c.description, ^search_pattern) or - ilike(c.keywords, ^search_pattern) or - ilike(c.position, ^search_pattern) + ilike(c.description, ^search_pattern) or + ilike(c.keywords, ^search_pattern) or + ilike(c.position, ^search_pattern) ) - _, query -> query + _, query -> + query end) end defp apply_component_sorting(query, opts) do case Keyword.get(opts, :sort_criteria, "name_asc") do - "name_asc" -> order_by(query, [c], [asc: c.name]) - "name_desc" -> order_by(query, [c], [desc: c.name]) - "inserted_at_asc" -> order_by(query, [c], [asc: c.inserted_at]) - "inserted_at_desc" -> order_by(query, [c], [desc: c.inserted_at]) - "updated_at_asc" -> order_by(query, [c], [asc: c.updated_at]) - "updated_at_desc" -> order_by(query, [c], [desc: c.updated_at]) - "count_asc" -> order_by(query, [c], [asc: c.count]) - "count_desc" -> order_by(query, [c], [desc: c.count]) - _ -> order_by(query, [c], [asc: c.name]) # Default fallback + "name_asc" -> order_by(query, [c], asc: c.name) + "name_desc" -> order_by(query, [c], desc: c.name) + "inserted_at_asc" -> order_by(query, [c], asc: c.inserted_at) + "inserted_at_desc" -> order_by(query, [c], desc: c.inserted_at) + "updated_at_asc" -> order_by(query, [c], asc: c.updated_at) + "updated_at_desc" -> order_by(query, [c], desc: c.updated_at) + "count_asc" -> order_by(query, [c], asc: c.count) + "count_desc" -> order_by(query, [c], desc: c.count) + # Default fallback + _ -> order_by(query, [c], asc: c.name) end end @@ -353,10 +384,12 @@ defmodule ComponentsElixir.Inventory do def get_inventory_stats do total_components = Repo.aggregate(Component, :count, :id) - total_stock = Component + total_stock = + Component |> Repo.aggregate(:sum, :count) - categories_with_components = Component + categories_with_components = + Component |> distinct([c], c.category_id) |> Repo.aggregate(:count, :category_id) @@ -406,6 +439,7 @@ defmodule ComponentsElixir.Inventory do """ def decrement_component_count(%Component{} = component) do new_count = max(0, component.count - 1) + component |> Component.changeset(%{count: new_count}) |> Repo.update() diff --git a/lib/components_elixir/inventory/storage_location.ex b/lib/components_elixir/inventory/storage_location.ex index 9e2d0d8..8e49233 100644 --- a/lib/components_elixir/inventory/storage_location.ex +++ b/lib/components_elixir/inventory/storage_location.ex @@ -15,7 +15,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do field :name, :string field :description, :string field :apriltag_id, :integer - field :is_active, :boolean, default: true # Computed/virtual fields - not stored in database field :level, :integer, virtual: true @@ -32,7 +31,7 @@ defmodule ComponentsElixir.Inventory.StorageLocation do @doc false def changeset(storage_location, attrs) do storage_location - |> cast(attrs, [:name, :description, :parent_id, :is_active, :apriltag_id]) + |> cast(attrs, [:name, :description, :parent_id, :apriltag_id]) |> validate_required([:name]) |> validate_length(:name, min: 1, max: 100) |> validate_length(:description, max: 500) diff --git a/lib/components_elixir_web/live/components_live.ex b/lib/components_elixir_web/live/components_live.ex index db2ad05..d36cf8a 100644 --- a/lib/components_elixir_web/live/components_live.ex +++ b/lib/components_elixir_web/live/components_live.ex @@ -97,6 +97,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do 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) @@ -119,20 +120,26 @@ defmodule ComponentsElixirWeb.ComponentsLive do 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"] - + 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") @@ -160,20 +167,26 @@ defmodule ComponentsElixirWeb.ComponentsLive do 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"] - + 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") @@ -269,9 +282,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do new_focused_id = if socket.assigns.focused_component_id == component_id do - nil # Unfocus if clicking on the same component + # Unfocus if clicking on the same component + nil else - component_id # Focus on the new component + # Focus on the new component + component_id end {:noreply, assign(socket, :focused_component_id, new_focused_id)} @@ -349,23 +364,26 @@ defmodule ComponentsElixirWeb.ComponentsLive do # 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 + + 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) + 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) @@ -383,7 +401,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do else # Frozen - just update the specific component in place without reordering if socket.assigns.interacting_with do - updated_components = + 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 @@ -392,7 +410,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do component end end) - + assign(socket, :components, updated_components) else socket @@ -485,8 +503,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do - - + +
@@ -553,7 +571,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do <%= if @sort_frozen do %>
- + Sort temporarily frozen
@@ -562,7 +580,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- + <%= if @show_add_form do %>
@@ -578,7 +596,13 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- <.form for={@form} phx-submit="save_component" phx-change="validate" multipart={true} class="space-y-4"> + <.form + for={@form} + phx-submit="save_component" + phx-change="validate" + multipart={true} + class="space-y-4" + >
<.input field={@form[:name]} type="text" required /> @@ -605,7 +629,9 @@ defmodule ComponentsElixirWeb.ComponentsLive do <.input field={@form[:keywords]} type="text" />
- + <.input field={@form[:storage_location_id]} type="select" @@ -628,14 +654,17 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- <.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" /> + <.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) %>

+

{Phoenix.Naming.humanize(err)}

<% end %> <%= for entry <- @uploads.image.entries do %> @@ -645,17 +674,23 @@ defmodule ComponentsElixirWeb.ComponentsLive do <.live_img_preview entry={entry} class="h-10 w-10 rounded-lg object-cover" />
-

<%= entry.client_name %>

-

<%= entry.progress %>%

+

{entry.client_name}

+

{entry.progress}%

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

<%= upload_error_to_string(err) %>

+

{upload_error_to_string(err)}

<% end %> @@ -679,7 +714,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do <% end %> - + <%= if @show_edit_form do %>
@@ -695,7 +730,13 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- <.form for={@form} phx-submit="save_edit" phx-change="validate" multipart={true} class="space-y-4"> + <.form + for={@form} + phx-submit="save_edit" + phx-change="validate" + multipart={true} + class="space-y-4" + >
<.input field={@form[:name]} type="text" required /> @@ -722,7 +763,9 @@ defmodule ComponentsElixirWeb.ComponentsLive do <.input field={@form[:keywords]} type="text" />
- + <.input field={@form[:storage_location_id]} type="select" @@ -747,18 +790,25 @@ defmodule ComponentsElixirWeb.ComponentsLive do <%= if @editing_component && @editing_component.image_filename do %>

Current image:

- Current component + 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" /> + <.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) %>

+

{Phoenix.Naming.humanize(err)}

<% end %> <%= for entry <- @uploads.image.entries do %> @@ -768,17 +818,23 @@ defmodule ComponentsElixirWeb.ComponentsLive do <.live_img_preview entry={entry} class="h-10 w-10 rounded-lg object-cover" />
-

<%= entry.client_name %>

-

<%= entry.progress %>%

+

{entry.client_name}

+

{entry.progress}%

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

<%= upload_error_to_string(err) %>

+

{upload_error_to_string(err)}

<% end %> @@ -802,17 +858,28 @@ defmodule ComponentsElixirWeb.ComponentsLive do <% end %> - +
    <%= for component <- @components do %> -
  • +
  • <%= if @focused_component_id == component.id do %>
    @@ -851,8 +918,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- - + +
@@ -870,12 +937,15 @@ defmodule ComponentsElixirWeb.ComponentsLive do <% else %>
- <.icon name="hero-cube-transparent" class="h-20 w-20 text-base-content/50" /> + <.icon + name="hero-cube-transparent" + class="h-20 w-20 text-base-content/50" + />
<% end %>
- - + +
<%= if component.description do %> @@ -886,15 +956,20 @@ defmodule ComponentsElixirWeb.ComponentsLive do

<% 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" /> + <.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)}
+
+ {storage_location_display_name(component.storage_location)} +
<% end %> @@ -906,10 +981,15 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- <.icon name="hero-calendar" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0" /> + <.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")}
+
+ {Calendar.strftime(component.inserted_at, "%B %d, %Y")} +
@@ -933,32 +1013,29 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- - + +
@@ -982,7 +1058,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do phx-value-url={"/user_generated/uploads/images/#{component.image_filename}"} class="hover:opacity-75 transition-opacity block" > - {component.name} + {component.name} <% else %>
@@ -1018,8 +1098,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do
- - + + <%= if component.description do %>

@@ -1027,14 +1107,19 @@ defmodule ComponentsElixirWeb.ComponentsLive do

<% 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" /> + <.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)} + + {storage_location_display_name(component.storage_location)} +
<% end %>
@@ -1050,8 +1135,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<% end %>
- - + + <%= if component.keywords do %>

@@ -1127,12 +1212,18 @@ defmodule ComponentsElixirWeb.ComponentsLive do <%= if @show_image_modal do %> -

+
- - -
+ + +

Component Image

@@ -1145,8 +1236,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do ×
- - + +
<%= if @modal_image_url do %> @@ -1192,6 +1283,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do :ok -> IO.puts("=== DEBUG: File copy successful ===") {:ok, filename} + {:error, reason} -> IO.puts("=== DEBUG: File copy failed: #{inspect(reason)} ===") {:postpone, {:error, reason}} @@ -1200,18 +1292,21 @@ defmodule ComponentsElixirWeb.ComponentsLive do 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 + 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 ===") diff --git a/priv/repo/migrations/20250917210658_remove_is_active_from_storage_locations.exs b/priv/repo/migrations/20250917210658_remove_is_active_from_storage_locations.exs new file mode 100644 index 0000000..5478c5f --- /dev/null +++ b/priv/repo/migrations/20250917210658_remove_is_active_from_storage_locations.exs @@ -0,0 +1,9 @@ +defmodule ComponentsElixir.Repo.Migrations.RemoveIsActiveFromStorageLocations do + use Ecto.Migration + + def change do + alter table(:storage_locations) do + remove :is_active, :boolean + end + end +end