From f62ddffb80c0681a7aface6217ecc7c452cea62e Mon Sep 17 00:00:00 2001 From: Kevin Sivic Date: Mon, 1 Dec 2025 00:58:58 -0500 Subject: [PATCH] Add admin screen for managing businesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AdminLive.Businesses at /admin/businesses for business management - Support deactivate/reactivate (soft delete) and permanent deletion - Filter tabs for All, Active, and Inactive businesses - Delete confirmation modal to prevent accidental deletion - Add admin functions to Businesses context (list_all, get_any, deactivate, activate, delete) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../live/admin_live/businesses.ex | 251 ++++++++++++++++++ test/localspot/businesses/admin_test.exs | 105 ++++++++ 2 files changed, 356 insertions(+) create mode 100644 lib/localspot_web/live/admin_live/businesses.ex create mode 100644 test/localspot/businesses/admin_test.exs diff --git a/lib/localspot_web/live/admin_live/businesses.ex b/lib/localspot_web/live/admin_live/businesses.ex new file mode 100644 index 0000000..5390c3b --- /dev/null +++ b/lib/localspot_web/live/admin_live/businesses.ex @@ -0,0 +1,251 @@ +defmodule LocalspotWeb.AdminLive.Businesses do + use LocalspotWeb, :live_view + + alias Localspot.Businesses + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Manage Businesses") + |> assign(:businesses, Businesses.list_all_businesses()) + |> assign(:filter, "all") + |> assign(:confirm_delete, nil)} + end + + @impl true + def handle_event("filter", %{"filter" => filter}, socket) do + {:noreply, + socket + |> assign(:filter, filter) + |> assign(:businesses, filter_businesses(filter))} + end + + @impl true + def handle_event("deactivate", %{"id" => id}, socket) do + business = Businesses.get_any_business(id) + + case Businesses.deactivate_business(business) do + {:ok, _business} -> + {:noreply, + socket + |> put_flash(:info, "#{business.name} has been deactivated") + |> assign(:businesses, filter_businesses(socket.assigns.filter))} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to deactivate business")} + end + end + + @impl true + def handle_event("activate", %{"id" => id}, socket) do + business = Businesses.get_any_business(id) + + case Businesses.activate_business(business) do + {:ok, _business} -> + {:noreply, + socket + |> put_flash(:info, "#{business.name} has been reactivated") + |> assign(:businesses, filter_businesses(socket.assigns.filter))} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to activate business")} + end + end + + @impl true + def handle_event("confirm_delete", %{"id" => id}, socket) do + {:noreply, assign(socket, :confirm_delete, id)} + end + + @impl true + def handle_event("cancel_delete", _params, socket) do + {:noreply, assign(socket, :confirm_delete, nil)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + business = Businesses.get_any_business(id) + + case Businesses.delete_business(business) do + {:ok, _business} -> + {:noreply, + socket + |> put_flash(:info, "#{business.name} has been permanently deleted") + |> assign(:confirm_delete, nil) + |> assign(:businesses, filter_businesses(socket.assigns.filter))} + + {:error, _changeset} -> + {:noreply, + socket + |> put_flash(:error, "Failed to delete business") + |> assign(:confirm_delete, nil)} + end + end + + defp filter_businesses("all"), do: Businesses.list_all_businesses() + + defp filter_businesses("active") do + Businesses.list_all_businesses() + |> Enum.filter(& &1.active) + end + + defp filter_businesses("inactive") do + Businesses.list_all_businesses() + |> Enum.filter(&(!&1.active)) + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + Manage Businesses + <:subtitle>View, deactivate, or remove businesses from the directory + <:actions> + <.link navigate={~p"/admin/import"} class="btn btn-outline btn-sm"> + <.icon name="hero-arrow-up-tray" class="w-4 h-4" /> Import + + + + +
+
+
+ + + +
+ +
+ {length(@businesses)} business(es) +
+
+ +
+ <.icon name="hero-information-circle" class="w-5 h-5" /> + No businesses found. +
+ +
+ + + + + + + + + + + + + + + + + + + +
NameCategoryLocationStatusActions
+
{business.name}
+
{business.slug}
+
+ + {business.category.name} + + — + +
{business.city}, {business.state}
+
+ Active + Inactive + +
+ <.link + navigate={~p"/businesses/#{business.slug}"} + class="btn btn-ghost btn-xs" + title="View" + > + <.icon name="hero-eye" class="w-4 h-4" /> + + + + + + + +
+
+
+
+ + + +
+ """ + end +end diff --git a/test/localspot/businesses/admin_test.exs b/test/localspot/businesses/admin_test.exs new file mode 100644 index 0000000..6d8b897 --- /dev/null +++ b/test/localspot/businesses/admin_test.exs @@ -0,0 +1,105 @@ +defmodule Localspot.Businesses.AdminTest do + use Localspot.DataCase + + alias Localspot.Businesses + + describe "admin functions" do + setup do + {:ok, category} = + Businesses.create_category(%{ + name: "Test Category", + slug: "test-category", + description: "For testing" + }) + + {:ok, business} = + Businesses.create_business(%{ + name: "Test Business", + slug: "test-business", + street_address: "123 Test St", + city: "Columbus", + state: "OH", + zip_code: "43215", + category_id: category.id, + active: true + }) + + %{category: category, business: business} + end + + test "list_all_businesses/0 returns all businesses including inactive", %{ + category: category, + business: business + } do + # Create an inactive business + {:ok, inactive} = + Businesses.create_business(%{ + name: "Inactive Business", + slug: "inactive-business", + street_address: "456 Test St", + city: "Columbus", + state: "OH", + zip_code: "43215", + category_id: category.id, + active: false + }) + + businesses = Businesses.list_all_businesses() + assert length(businesses) == 2 + assert Enum.any?(businesses, &(&1.id == business.id)) + assert Enum.any?(businesses, &(&1.id == inactive.id)) + end + + test "get_any_business/1 returns inactive businesses", %{category: category} do + {:ok, inactive} = + Businesses.create_business(%{ + name: "Inactive Business", + slug: "inactive-business", + street_address: "456 Test St", + city: "Columbus", + state: "OH", + zip_code: "43215", + category_id: category.id, + active: false + }) + + # Regular get_business should not find it + assert is_nil(Businesses.get_business(inactive.id)) + + # But get_any_business should + found = Businesses.get_any_business(inactive.id) + assert found.id == inactive.id + end + + test "deactivate_business/1 sets active to false", %{business: business} do + assert business.active == true + + {:ok, deactivated} = Businesses.deactivate_business(business) + assert deactivated.active == false + + # Should no longer appear in regular listing + businesses = Businesses.list_businesses() + refute Enum.any?(businesses, &(&1.id == business.id)) + end + + test "activate_business/1 sets active to true", %{business: business} do + {:ok, deactivated} = Businesses.deactivate_business(business) + assert deactivated.active == false + + {:ok, reactivated} = Businesses.activate_business(deactivated) + assert reactivated.active == true + + # Should appear in regular listing again + businesses = Businesses.list_businesses() + assert Enum.any?(businesses, &(&1.id == business.id)) + end + + test "delete_business/1 permanently removes the business", %{business: business} do + {:ok, _deleted} = Businesses.delete_business(business) + + # Should not exist anywhere + assert is_nil(Businesses.get_business(business.id)) + assert is_nil(Businesses.get_any_business(business.id)) + end + end +end