add activity log with order timeline and global feed
All checks were successful
deploy / deploy (push) Successful in 4m22s
All checks were successful
deploy / deploy (push) Successful in 4m22s
Single activity_log table powering two views: chronological timeline on each order detail page (replacing the old fulfilment card) and a global feed at /admin/activity with tabs, category filters, search, and pagination. Real-time via PubSub — new entries appear instantly, nav badge updates across all admin pages. Instrumented across all event points: Stripe webhooks, order notifier, submission worker, fulfilment status worker, product sync worker, and Oban exhausted-job telemetry. Contextual action buttons (retry submission, retry sync, dismiss) with Oban unique constraints to prevent double-enqueue. 90-day pruning via cron. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
||||
"""
|
||||
import Phoenix.Component
|
||||
|
||||
alias Berrypod.Settings
|
||||
alias Berrypod.{ActivityLog, Settings}
|
||||
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
||||
|
||||
def on_mount(:assign_current_path, _params, _session, socket) do
|
||||
@@ -22,6 +22,8 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
||||
css
|
||||
end
|
||||
|
||||
if Phoenix.LiveView.connected?(socket), do: ActivityLog.subscribe()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:current_path, "")
|
||||
@@ -29,6 +31,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
||||
|> assign(:email_configured, Berrypod.Mailer.email_configured?())
|
||||
|> assign(:theme_settings, theme_settings)
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:attention_count, ActivityLog.count_needing_attention())
|
||||
|> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,
|
||||
uri,
|
||||
socket ->
|
||||
@@ -37,6 +40,13 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
||||
|> assign(:current_path, URI.parse(uri).path)
|
||||
|> assign(:site_live, Settings.site_live?())}
|
||||
end)
|
||||
|> Phoenix.LiveView.attach_hook(:update_attention_count, :handle_info, fn
|
||||
{:new_activity, _entry}, socket ->
|
||||
{:cont, assign(socket, :attention_count, ActivityLog.count_needing_attention())}
|
||||
|
||||
_other, socket ->
|
||||
{:cont, socket}
|
||||
end)
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
|
||||
@@ -80,6 +80,20 @@
|
||||
<.icon name="hero-shopping-bag" class="size-5" /> Orders
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/activity"}
|
||||
class={admin_nav_active?(@current_path, "/admin/activity")}
|
||||
>
|
||||
<.icon name="hero-bell" class="size-5" /> Activity
|
||||
<span
|
||||
:if={@attention_count > 0}
|
||||
class="admin-badge admin-badge-sm admin-badge-warning ml-auto"
|
||||
>
|
||||
{@attention_count}
|
||||
</span>
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/products"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule BerrypodWeb.StripeWebhookController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.{ActivityLog, Orders}
|
||||
alias Berrypod.Orders.{OrderNotifier, OrderSubmissionWorker}
|
||||
|
||||
require Logger
|
||||
@@ -69,6 +69,17 @@ defmodule BerrypodWeb.StripeWebhookController do
|
||||
{:order_paid, order}
|
||||
)
|
||||
|
||||
ActivityLog.log_event(
|
||||
"order.created",
|
||||
"Order placed — #{Berrypod.Cart.format_price(order.total)} via Stripe",
|
||||
order_id: order.id,
|
||||
payload: %{
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
payment_intent: payment_intent_id
|
||||
}
|
||||
)
|
||||
|
||||
OrderNotifier.deliver_order_confirmation(order)
|
||||
|
||||
# Submit to fulfilment provider
|
||||
@@ -138,6 +149,14 @@ defmodule BerrypodWeb.StripeWebhookController do
|
||||
case Orders.create_abandoned_cart(attrs) do
|
||||
{:ok, cart} ->
|
||||
Berrypod.Orders.AbandonedCartEmailWorker.enqueue(cart.id)
|
||||
|
||||
ActivityLog.log_event(
|
||||
"abandoned_cart.created",
|
||||
"Abandoned cart — #{email}, #{Berrypod.Cart.format_price(order.total)}",
|
||||
order_id: order.id,
|
||||
payload: %{email: email, total: order.total}
|
||||
)
|
||||
|
||||
Logger.info("Abandoned cart recorded for #{email}, recovery email scheduled")
|
||||
|
||||
{:error, reason} ->
|
||||
|
||||
407
lib/berrypod_web/live/admin/activity.ex
Normal file
407
lib/berrypod_web/live/admin/activity.ex
Normal file
@@ -0,0 +1,407 @@
|
||||
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
|
||||
</.header>
|
||||
|
||||
<%!-- tabs --%>
|
||||
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
|
||||
<button
|
||||
phx-click="tab"
|
||||
phx-value-tab="all"
|
||||
class={[
|
||||
"admin-btn admin-btn-sm",
|
||||
@tab == "all" && "admin-btn-primary",
|
||||
@tab != "all" && "admin-btn-ghost"
|
||||
]}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
phx-click="tab"
|
||||
phx-value-tab="attention"
|
||||
class={[
|
||||
"admin-btn admin-btn-sm",
|
||||
@tab == "attention" && "admin-btn-primary",
|
||||
@tab != "attention" && "admin-btn-ghost"
|
||||
]}
|
||||
>
|
||||
Needs attention
|
||||
<span
|
||||
:if={@attention_count > 0}
|
||||
class="admin-badge admin-badge-sm admin-badge-warning ml-1"
|
||||
>
|
||||
{@attention_count}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- category chips + search --%>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<.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" />
|
||||
<div class="ml-auto">
|
||||
<.form for={%{}} phx-submit="search" as={:search} class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
name="search[query]"
|
||||
value={@search}
|
||||
placeholder="Search by order number"
|
||||
class="admin-input admin-input-sm"
|
||||
/>
|
||||
<button type="submit" class="admin-btn admin-btn-sm admin-btn-ghost">
|
||||
<.icon name="hero-magnifying-glass-mini" class="size-4" />
|
||||
</button>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- entries --%>
|
||||
<div id="activity-entries" phx-update="stream">
|
||||
<div id="activity-empty" class="hidden only:block text-sm text-base-content/60 py-8 text-center">
|
||||
No activity to show.
|
||||
</div>
|
||||
<div :for={{dom_id, entry} <- @streams.entries} id={dom_id} class="admin-activity-row">
|
||||
<div class={["admin-activity-icon", activity_icon_class(entry.level)]}>
|
||||
<.icon name={activity_icon(entry.level)} class="size-4" />
|
||||
</div>
|
||||
<div class="admin-activity-body">
|
||||
<span class="admin-activity-type">{format_event_type(entry.event_type)}</span>
|
||||
<span>{entry.message}</span>
|
||||
<.link
|
||||
:if={entry.order_id}
|
||||
navigate={~p"/admin/orders/#{entry.order_id}"}
|
||||
class="text-primary hover:underline text-xs ml-1"
|
||||
>
|
||||
View order →
|
||||
</.link>
|
||||
</div>
|
||||
<div class="admin-activity-meta">
|
||||
<time class="admin-activity-time" datetime={DateTime.to_iso8601(entry.occurred_at)}>
|
||||
{relative_time(entry.occurred_at)}
|
||||
</time>
|
||||
<.entry_action entry={entry} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.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"""
|
||||
<button
|
||||
phx-click="category"
|
||||
phx-value-category={@category || ""}
|
||||
class={[
|
||||
"admin-btn admin-btn-xs",
|
||||
@active && "admin-btn-primary",
|
||||
!@active && "admin-btn-ghost"
|
||||
]}
|
||||
>
|
||||
{@label}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp entry_action(
|
||||
%{entry: %{event_type: "order.submission_failed", order_id: order_id}} = assigns
|
||||
)
|
||||
when not is_nil(order_id) do
|
||||
~H"""
|
||||
<button
|
||||
phx-click="retry_submission"
|
||||
phx-value-order-id={@entry.order_id}
|
||||
phx-value-entry-id={@entry.id}
|
||||
class="admin-btn admin-btn-xs admin-btn-primary"
|
||||
>
|
||||
Retry submission
|
||||
</button>
|
||||
"""
|
||||
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"""
|
||||
<button
|
||||
:if={@connection_id}
|
||||
phx-click="retry_sync"
|
||||
phx-value-connection-id={@connection_id}
|
||||
phx-value-entry-id={@entry.id}
|
||||
class="admin-btn admin-btn-xs admin-btn-primary"
|
||||
>
|
||||
Retry sync
|
||||
</button>
|
||||
<button
|
||||
:if={!@connection_id}
|
||||
phx-click="resolve"
|
||||
phx-value-id={@entry.id}
|
||||
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
"""
|
||||
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"""
|
||||
<button
|
||||
:if={@order_related && @entry.order_id}
|
||||
phx-click="retry_submission"
|
||||
phx-value-order-id={@entry.order_id}
|
||||
phx-value-entry-id={@entry.id}
|
||||
class="admin-btn admin-btn-xs admin-btn-primary"
|
||||
>
|
||||
Retry submission
|
||||
</button>
|
||||
<button
|
||||
:if={!@order_related || !@entry.order_id}
|
||||
phx-click="resolve"
|
||||
phx-value-id={@entry.id}
|
||||
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp entry_action(%{entry: %{level: level, resolved_at: nil}} = assigns)
|
||||
when level in ["warning", "error"] do
|
||||
~H"""
|
||||
<button
|
||||
phx-click="resolve"
|
||||
phx-value-id={@entry.id}
|
||||
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
"""
|
||||
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: "text-red-500"
|
||||
defp activity_icon_class("warning"), do: "text-amber-500"
|
||||
defp activity_icon_class(_), do: "text-green-500"
|
||||
|
||||
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
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule BerrypodWeb.Admin.OrderShow do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.{ActivityLog, Orders}
|
||||
alias Berrypod.Cart
|
||||
|
||||
@impl true
|
||||
@@ -16,15 +16,25 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
{:ok, socket}
|
||||
|
||||
order ->
|
||||
if connected?(socket), do: ActivityLog.subscribe(order.id)
|
||||
|
||||
timeline = ActivityLog.list_for_order(order.id)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, order.order_number)
|
||||
|> assign(:order, order)
|
||||
|> assign(:timeline, timeline)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:new_activity, entry}, socket) do
|
||||
{:noreply, assign(socket, :timeline, socket.assigns.timeline ++ [entry])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
@@ -95,43 +105,14 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- fulfilment --%>
|
||||
<%!-- timeline --%>
|
||||
<div class="admin-card mt-6">
|
||||
<div class="admin-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="admin-card-title">Fulfilment</h3>
|
||||
<h3 class="admin-card-title">Timeline</h3>
|
||||
<.fulfilment_badge status={@order.fulfilment_status} />
|
||||
</div>
|
||||
<.list>
|
||||
<:item :if={@order.provider_order_id} title="Provider order ID">
|
||||
<code class="text-xs">{@order.provider_order_id}</code>
|
||||
</:item>
|
||||
<:item :if={@order.provider_status} title="Provider status">
|
||||
{@order.provider_status}
|
||||
</:item>
|
||||
<:item :if={@order.submitted_at} title="Submitted">
|
||||
{format_date(@order.submitted_at)}
|
||||
</:item>
|
||||
<:item :if={@order.tracking_number} title="Tracking">
|
||||
<%= if @order.tracking_url do %>
|
||||
<a href={@order.tracking_url} target="_blank" class="admin-link">
|
||||
{@order.tracking_number}
|
||||
</a>
|
||||
<% else %>
|
||||
{@order.tracking_number}
|
||||
<% end %>
|
||||
</:item>
|
||||
<:item :if={@order.shipped_at} title="Shipped">
|
||||
{format_date(@order.shipped_at)}
|
||||
</:item>
|
||||
<:item :if={@order.delivered_at} title="Delivered">
|
||||
{format_date(@order.delivered_at)}
|
||||
</:item>
|
||||
<:item :if={@order.fulfilment_error} title="Error">
|
||||
<span class="text-error text-sm">{@order.fulfilment_error}</span>
|
||||
</:item>
|
||||
</.list>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<div class="flex gap-2 mt-2 mb-4">
|
||||
<button
|
||||
:if={can_submit?(@order)}
|
||||
phx-click="submit_to_provider"
|
||||
@@ -150,6 +131,23 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
:if={@order.tracking_number not in [nil, ""]}
|
||||
class="flex items-center gap-2 mb-4 text-sm"
|
||||
>
|
||||
<.icon name="hero-truck-mini" class="size-4 text-base-content/60" />
|
||||
<span class="font-medium">{@order.tracking_number}</span>
|
||||
<a
|
||||
:if={@order.tracking_url not in [nil, ""]}
|
||||
href={@order.tracking_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
Track shipment →
|
||||
</a>
|
||||
</div>
|
||||
<.order_timeline entries={@timeline} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -234,6 +232,43 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
||||
end
|
||||
end
|
||||
|
||||
# ── Components ──
|
||||
|
||||
defp order_timeline(assigns) do
|
||||
~H"""
|
||||
<div :if={@entries == []} class="text-sm text-base-content/60 py-4">
|
||||
No activity recorded yet.
|
||||
</div>
|
||||
<ol :if={@entries != []} class="admin-timeline" id="order-timeline">
|
||||
<li :for={entry <- @entries} class="admin-timeline-item">
|
||||
<div class={["admin-timeline-dot", timeline_dot_class(entry.level)]}></div>
|
||||
<div class="admin-timeline-content">
|
||||
<p class="admin-timeline-message">{entry.message}</p>
|
||||
<time class="admin-timeline-time" datetime={DateTime.to_iso8601(entry.occurred_at)}>
|
||||
{format_timeline_time(entry.occurred_at)}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
"""
|
||||
end
|
||||
|
||||
defp timeline_dot_class("error"), do: "admin-timeline-dot-error"
|
||||
defp timeline_dot_class("warning"), do: "admin-timeline-dot-warning"
|
||||
defp timeline_dot_class(_), do: "admin-timeline-dot-info"
|
||||
|
||||
defp format_timeline_time(datetime) do
|
||||
today = Date.utc_today()
|
||||
event_date = DateTime.to_date(datetime)
|
||||
diff_days = Date.diff(today, event_date)
|
||||
|
||||
cond do
|
||||
diff_days == 0 -> Calendar.strftime(datetime, "%H:%M")
|
||||
diff_days < 7 -> Calendar.strftime(datetime, "%d %b %H:%M")
|
||||
true -> Calendar.strftime(datetime, "%d %b %Y")
|
||||
end
|
||||
end
|
||||
|
||||
defp can_submit?(order) do
|
||||
order.payment_status == "paid" and order.fulfilment_status in ["unfulfilled", "failed"]
|
||||
end
|
||||
|
||||
@@ -148,6 +148,7 @@ defmodule BerrypodWeb.Router do
|
||||
live "/analytics", Admin.Analytics, :index
|
||||
live "/orders", Admin.Orders, :index
|
||||
live "/orders/:id", Admin.OrderShow, :show
|
||||
live "/activity", Admin.Activity, :index
|
||||
live "/products", Admin.Products, :index
|
||||
live "/products/:id", Admin.ProductShow, :show
|
||||
live "/providers", Admin.Providers.Index, :index
|
||||
|
||||
Reference in New Issue
Block a user