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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user