defmodule BerrypodWeb.Admin.Activity do use BerrypodWeb, :live_view alias Berrypod.{ActivityLog, Orders} alias Berrypod.Sync.ProductSyncWorker @valid_tabs ~w(all attention) @valid_categories ~w(orders syncs emails carts) @impl true def mount(_params, _session, socket) do if connected?(socket), do: ActivityLog.subscribe() socket = socket |> assign(:page_title, "Activity") |> assign(:attention_count, ActivityLog.count_needing_attention()) {:ok, socket} end @impl true def handle_params(params, _uri, socket) do tab = if params["tab"] in @valid_tabs, do: params["tab"], else: "all" category = if params["category"] in @valid_categories, do: params["category"], else: nil search = params["search"] page_num = Berrypod.Pagination.parse_page(params) page = ActivityLog.list_recent( page: page_num, tab: tab, category: category, search: search ) socket = socket |> assign(:tab, tab) |> assign(:category, category) |> assign(:search, search || "") |> assign(:pagination, page) |> stream(:entries, page.items, reset: true) {:noreply, socket} end @impl true def handle_info({:new_activity, entry}, socket) do socket = assign(socket, :attention_count, ActivityLog.count_needing_attention()) # Only prepend to stream if on page 1 and entry matches current filters socket = if socket.assigns.pagination.page == 1 and matches_filters?(entry, socket.assigns) do stream_insert(socket, :entries, entry, at: 0) else socket end {:noreply, socket} end @impl true def handle_event("tab", %{"tab" => tab}, socket) do params = build_params(tab: tab) {:noreply, push_patch(socket, to: ~p"/admin/activity?#{params}")} end def handle_event("category", %{"category" => category}, socket) do # Toggle: clicking the active category clears it category = if category == socket.assigns.category, do: nil, else: category params = build_params(tab: socket.assigns.tab, category: category, search: socket.assigns.search) {:noreply, push_patch(socket, to: ~p"/admin/activity?#{params}")} end def handle_event("search", %{"search" => %{"query" => query}}, socket) do search = if query == "", do: nil, else: query params = build_params(tab: socket.assigns.tab, category: socket.assigns.category, search: search) {:noreply, push_patch(socket, to: ~p"/admin/activity?#{params}")} end def handle_event("resolve", %{"id" => id}, socket) do ActivityLog.resolve(id) {:noreply, refetch_and_reassign(socket)} end def handle_event("retry_submission", %{"order-id" => order_id, "entry-id" => entry_id}, socket) do ActivityLog.resolve(entry_id) case Orders.OrderSubmissionWorker.enqueue(order_id) do {:ok, _job} -> {:noreply, socket |> put_flash(:info, "Submission retry enqueued") |> refetch_and_reassign()} {:error, _reason} -> {:noreply, put_flash(socket, :error, "Failed to enqueue retry")} end end def handle_event( "retry_sync", %{"connection-id" => connection_id, "entry-id" => entry_id}, socket ) do ActivityLog.resolve(entry_id) case ProductSyncWorker.enqueue(connection_id) do {:ok, _job} -> {:noreply, socket |> put_flash(:info, "Sync retry enqueued") |> refetch_and_reassign()} {:error, _reason} -> {:noreply, put_flash(socket, :error, "Failed to enqueue sync")} end end @impl true def render(assigns) do ~H""" <.header> Activity <%!-- tabs --%>
<%!-- category chips + search --%>
<.category_chip category={nil} active={@category} label="All" /> <.category_chip category="orders" active={@category} label="Orders" /> <.category_chip category="syncs" active={@category} label="Syncs" /> <.category_chip category="emails" active={@category} label="Emails" /> <.category_chip category="carts" active={@category} label="Carts" />
<.form for={%{}} phx-submit="search" as={:search} class="admin-row" >
<%!-- entries --%>
No activity to show.
<.icon name={activity_icon(entry.level)} class="size-4" />
{format_event_type(entry.event_type)} {entry.message} <.link :if={entry.order_id} navigate={~p"/admin/orders/#{entry.order_id}"} class="admin-activity-link" > View order →
<.entry_action entry={entry} />
<.admin_pagination page={@pagination} patch={~p"/admin/activity"} params={build_params(tab: @tab, category: @category, search: @search)} /> """ end # ── Components ── defp category_chip(assigns) do active = assigns.category == assigns.active assigns = assign(assigns, :active, active) ~H""" """ end defp entry_action( %{entry: %{event_type: "order.submission_failed", order_id: order_id}} = assigns ) when not is_nil(order_id) do ~H""" """ end defp entry_action(%{entry: %{event_type: "sync.failed", payload: payload}} = assigns) do connection_id = payload["connection_id"] || payload[:connection_id] assigns = assign(assigns, :connection_id, connection_id) ~H""" """ end defp entry_action(%{entry: %{event_type: "job.exhausted", payload: payload}} = assigns) do # If the exhausted job was an order submission worker and we have an order_id, offer retry worker = payload["worker"] || payload[:worker] || "" order_related = String.contains?(worker, "OrderSubmission") assigns = assign(assigns, :order_related, order_related) ~H""" """ end defp entry_action(%{entry: %{level: level, resolved_at: nil}} = assigns) when level in ["warning", "error"] do ~H""" """ end defp entry_action(assigns) do ~H"" end # ── Helpers ── defp activity_icon("error"), do: "hero-x-circle-mini" defp activity_icon("warning"), do: "hero-exclamation-triangle-mini" defp activity_icon(_), do: "hero-check-circle-mini" defp activity_icon_class("error"), do: "admin-activity-icon-error" defp activity_icon_class("warning"), do: "admin-activity-icon-warning" defp activity_icon_class(_), do: "admin-activity-icon-ok" defp format_event_type(event_type) do event_type |> String.replace(".", " ") |> String.replace("_", " ") end defp relative_time(datetime) do now = DateTime.utc_now() diff = DateTime.diff(now, datetime, :second) cond do diff < 60 -> "just now" diff < 3600 -> "#{div(diff, 60)}m ago" diff < 86400 -> "#{div(diff, 3600)}h ago" diff < 604_800 -> "#{div(diff, 86400)}d ago" true -> Calendar.strftime(datetime, "%d %b %Y") end end defp matches_filters?(entry, assigns) do tab_match = case assigns.tab do "attention" -> entry.level in ["warning", "error"] and is_nil(entry.resolved_at) _ -> true end category_match = case assigns.category do "orders" -> String.starts_with?(entry.event_type, "order.") "syncs" -> String.starts_with?(entry.event_type, "sync.") "emails" -> String.contains?(entry.event_type, ".email.") "carts" -> String.starts_with?(entry.event_type, "abandoned_cart.") _ -> true end tab_match and category_match end defp build_params(opts) do %{} |> then(fn p -> if opts[:tab] && opts[:tab] != "all", do: Map.put(p, "tab", opts[:tab]), else: p end) |> then(fn p -> if opts[:category], do: Map.put(p, "category", opts[:category]), else: p end) |> then(fn p -> if opts[:search], do: Map.put(p, "search", opts[:search]), else: p end) end defp refetch_page(assigns) do ActivityLog.list_recent( page: assigns.pagination.page, tab: assigns.tab, category: assigns.category, search: assigns.search ) end defp refetch_and_reassign(socket) do page = refetch_page(socket.assigns) socket |> assign(:pagination, page) |> assign(:attention_count, ActivityLog.count_needing_attention()) |> stream(:entries, page.items, reset: true) end end