berrypod/lib/berrypod_web/live/admin/order_show.ex
jamey 22d3e36ed5 format refactored admin templates
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:39:56 +00:00

326 lines
11 KiB
Elixir

defmodule BerrypodWeb.Admin.OrderShow do
use BerrypodWeb, :live_view
alias Berrypod.{ActivityLog, Orders}
alias Berrypod.Cart
@impl true
def mount(%{"id" => id}, _session, socket) do
case Orders.get_order(id) do
nil ->
socket =
socket
|> put_flash(:error, "Order not found")
|> push_navigate(to: ~p"/admin/orders")
{: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"""
<.header>
<.link navigate={~p"/admin/orders"} class="admin-link-subtle" style="font-weight: 400;">
&larr; Orders
</.link>
<div class="admin-row" style="--admin-row-gap: 0.75rem; margin-top: 0.25rem;">
<span style="font-size: 1.5rem; font-weight: 700;">{@order.order_number}</span>
<.status_badge status={@order.payment_status} />
</div>
</.header>
<div
class="admin-grid"
style="--admin-grid-min: 20rem; --admin-grid-gap: 1.5rem; margin-top: 1.5rem;"
>
<%!-- order info --%>
<div class="admin-card">
<div class="admin-card-body">
<h3 class="admin-card-title">Order details</h3>
<.list>
<:item title="Date">{format_date(@order.inserted_at)}</:item>
<:item title="Customer">{@order.customer_email || "—"}</:item>
<:item title="Payment status">
<.status_badge status={@order.payment_status} />
</:item>
<:item :if={@order.stripe_payment_intent_id} title="Stripe payment">
<code style="font-size: 0.75rem;">{@order.stripe_payment_intent_id}</code>
</:item>
<:item title="Currency">{String.upcase(@order.currency)}</:item>
</.list>
</div>
</div>
<%!-- shipping address --%>
<div class="admin-card">
<div class="admin-card-body">
<h3 class="admin-card-title">Shipping address</h3>
<%= if @order.shipping_address != %{} do %>
<.list>
<:item :if={@order.shipping_address["name"]} title="Name">
{@order.shipping_address["name"]}
</:item>
<:item :if={@order.shipping_address["line1"]} title="Address">
{@order.shipping_address["line1"]}
<span :if={@order.shipping_address["line2"]}>
<br />{@order.shipping_address["line2"]}
</span>
</:item>
<:item :if={@order.shipping_address["city"]} title="City">
{@order.shipping_address["city"]}
</:item>
<:item :if={@order.shipping_address["state"] not in [nil, ""]} title="State">
{@order.shipping_address["state"]}
</:item>
<:item :if={@order.shipping_address["postal_code"]} title="Postcode">
{@order.shipping_address["postal_code"]}
</:item>
<:item :if={@order.shipping_address["country"]} title="Country">
{@order.shipping_address["country"]}
</:item>
</.list>
<% else %>
<p class="admin-section-desc" style="margin-top: 0;">No shipping address provided</p>
<% end %>
</div>
</div>
</div>
<%!-- timeline --%>
<div class="admin-card" style="margin-top: 1.5rem;">
<div class="admin-card-body">
<div class="admin-row" style="justify-content: space-between;">
<h3 class="admin-card-title">Timeline</h3>
<.fulfilment_badge status={@order.fulfilment_status} />
</div>
<div class="admin-row" style="margin-top: 0.5rem; margin-bottom: 1rem;">
<button
:if={can_submit?(@order)}
phx-click="submit_to_provider"
class="admin-btn admin-btn-primary admin-btn-sm"
>
<.icon name="hero-paper-airplane-mini" class="size-4" />
{if @order.fulfilment_status == "failed",
do: "Retry submission",
else: "Submit to provider"}
</button>
<button
:if={can_refresh?(@order)}
phx-click="refresh_status"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
</button>
</div>
<div
:if={@order.tracking_number not in [nil, ""]}
class="admin-row"
style="margin-bottom: 1rem; font-size: 0.875rem;"
>
<span class="admin-text-secondary"><.icon name="hero-truck-mini" class="size-4" /></span>
<span style="font-weight: 500;">{@order.tracking_number}</span>
<a
:if={@order.tracking_url not in [nil, ""]}
href={@order.tracking_url}
target="_blank"
rel="noopener"
class="admin-link"
>
Track shipment &rarr;
</a>
</div>
<.order_timeline entries={@timeline} />
</div>
</div>
<%!-- line items --%>
<div class="admin-card" style="margin-top: 1.5rem;">
<div class="admin-card-body">
<h3 class="admin-card-title">Items</h3>
<table class="admin-table admin-table-zebra">
<thead>
<tr>
<th>Product</th>
<th>Variant</th>
<th style="text-align: end;">Qty</th>
<th style="text-align: end;">Unit price</th>
<th style="text-align: end;">Total</th>
</tr>
</thead>
<tbody>
<tr :for={item <- @order.items}>
<td>{item.product_name}</td>
<td>{item.variant_title}</td>
<td style="text-align: end;">{item.quantity}</td>
<td style="text-align: end;">{Cart.format_price(item.unit_price)}</td>
<td style="text-align: end;">{Cart.format_price(item.unit_price * item.quantity)}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="4" style="text-align: end; font-weight: 500;">Subtotal</td>
<td style="text-align: end; font-weight: 500;">{Cart.format_price(@order.subtotal)}</td>
</tr>
<tr style="font-size: 1.125rem;">
<td colspan="4" style="text-align: end; font-weight: 700;">Total</td>
<td style="text-align: end; font-weight: 700;">{Cart.format_price(@order.total)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
"""
end
@impl true
def handle_event("submit_to_provider", _params, socket) do
order = socket.assigns.order
case Orders.submit_to_provider(order) do
{:ok, updated} ->
socket =
socket
|> assign(:order, updated)
|> put_flash(:info, "Order submitted to provider")
{:noreply, socket}
{:error, _reason} ->
order = Orders.get_order(order.id)
socket =
socket
|> assign(:order, order)
|> put_flash(:error, order.fulfilment_error || "Submission failed")
{:noreply, socket}
end
end
def handle_event("refresh_status", _params, socket) do
order = socket.assigns.order
case Orders.refresh_fulfilment_status(order) do
{:ok, updated} ->
socket =
socket
|> assign(:order, updated)
|> put_flash(:info, "Status refreshed")
{:noreply, socket}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to refresh: #{inspect(reason)}")}
end
end
# ── Components ──
defp order_timeline(assigns) do
~H"""
<div :if={@entries == []} class="admin-section-desc" style="padding-block: 1rem; margin-top: 0;">
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
defp can_refresh?(order) do
not is_nil(order.provider_order_id) and
order.fulfilment_status in ["submitted", "processing", "shipped"]
end
defp fulfilment_badge(assigns) do
{color, icon} =
case assigns.status do
"submitted" -> {"admin-status-pill-blue", "hero-paper-airplane-mini"}
"processing" -> {"admin-status-pill-amber", "hero-cog-6-tooth-mini"}
"shipped" -> {"admin-status-pill-purple", "hero-truck-mini"}
"delivered" -> {"admin-status-pill-green", "hero-check-circle-mini"}
"failed" -> {"admin-status-pill-red", "hero-x-circle-mini"}
"cancelled" -> {"admin-status-pill-zinc", "hero-no-symbol-mini"}
_ -> {"admin-status-pill-zinc", "hero-minus-circle-mini"}
end
assigns = assign(assigns, color: color, icon: icon)
~H"""
<span class={["admin-status-pill", @color]}>
<.icon name={@icon} class="size-3" /> {@status}
</span>
"""
end
defp status_badge(assigns) do
{color, icon} =
case assigns.status do
"paid" -> {"admin-status-pill-green", "hero-check-circle-mini"}
"pending" -> {"admin-status-pill-amber", "hero-clock-mini"}
"failed" -> {"admin-status-pill-red", "hero-x-circle-mini"}
"refunded" -> {"admin-status-pill-zinc", "hero-arrow-uturn-left-mini"}
_ -> {"admin-status-pill-zinc", "hero-question-mark-circle-mini"}
end
assigns = assign(assigns, color: color, icon: icon)
~H"""
<span class={["admin-status-pill", @color]}>
<.icon name={@icon} class="size-3" /> {@status}
</span>
"""
end
defp format_date(datetime) do
Calendar.strftime(datetime, "%d %b %Y %H:%M")
end
end