All modules, configs, paths, and references updated. 836 tests pass, zero warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
326 lines
10 KiB
Elixir
326 lines
10 KiB
Elixir
defmodule BerrypodWeb.Admin.OrderShow do
|
|
use BerrypodWeb, :live_view
|
|
|
|
alias Berrypod.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 ->
|
|
socket =
|
|
socket
|
|
|> assign(:page_title, order.order_number)
|
|
|> assign(:order, order)
|
|
|
|
{:ok, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<.header>
|
|
<.link
|
|
navigate={~p"/admin/orders"}
|
|
class="text-sm font-normal text-base-content/60 hover:underline"
|
|
>
|
|
← Orders
|
|
</.link>
|
|
<div class="flex items-center gap-3 mt-1">
|
|
<span class="text-2xl font-bold">{@order.order_number}</span>
|
|
<.status_badge status={@order.payment_status} />
|
|
</div>
|
|
</.header>
|
|
|
|
<div class="grid gap-6 mt-6 lg:grid-cols-2">
|
|
<%!-- 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 class="text-xs">{@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="text-base-content/60 text-sm">No shipping address provided</p>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- fulfilment --%>
|
|
<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>
|
|
<.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">
|
|
<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>
|
|
</div>
|
|
|
|
<%!-- line items --%>
|
|
<div class="admin-card mt-6">
|
|
<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 class="text-right">Qty</th>
|
|
<th class="text-right">Unit price</th>
|
|
<th class="text-right">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={item <- @order.items}>
|
|
<td>{item.product_name}</td>
|
|
<td>{item.variant_title}</td>
|
|
<td class="text-right">{item.quantity}</td>
|
|
<td class="text-right">{Cart.format_price(item.unit_price)}</td>
|
|
<td class="text-right">{Cart.format_price(item.unit_price * item.quantity)}</td>
|
|
</tr>
|
|
</tbody>
|
|
<tfoot>
|
|
<tr>
|
|
<td colspan="4" class="text-right font-medium">Subtotal</td>
|
|
<td class="text-right font-medium">{Cart.format_price(@order.subtotal)}</td>
|
|
</tr>
|
|
<tr class="text-lg">
|
|
<td colspan="4" class="text-right font-bold">Total</td>
|
|
<td class="text-right font-bold">{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
|
|
|
|
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
|
|
{bg, text, ring, icon} =
|
|
case assigns.status do
|
|
"submitted" ->
|
|
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
|
|
|
|
"processing" ->
|
|
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
|
|
|
|
"shipped" ->
|
|
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
|
|
|
|
"delivered" ->
|
|
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
|
|
|
"failed" ->
|
|
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
|
|
|
"cancelled" ->
|
|
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
|
"hero-no-symbol-mini"}
|
|
|
|
_ ->
|
|
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
|
"hero-minus-circle-mini"}
|
|
end
|
|
|
|
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
|
|
|
~H"""
|
|
<span class={[
|
|
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
|
@bg,
|
|
@text,
|
|
@ring
|
|
]}>
|
|
<.icon name={@icon} class="size-3" /> {@status}
|
|
</span>
|
|
"""
|
|
end
|
|
|
|
defp status_badge(assigns) do
|
|
{bg, text, ring, icon} =
|
|
case assigns.status do
|
|
"paid" ->
|
|
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
|
|
|
|
"pending" ->
|
|
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"}
|
|
|
|
"failed" ->
|
|
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
|
|
|
|
"refunded" ->
|
|
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
|
"hero-arrow-uturn-left-mini"}
|
|
|
|
_ ->
|
|
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
|
|
"hero-question-mark-circle-mini"}
|
|
end
|
|
|
|
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
|
|
|
|
~H"""
|
|
<span class={[
|
|
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
|
@bg,
|
|
@text,
|
|
@ring
|
|
]}>
|
|
<.icon name={@icon} class="size-3" /> {@status}
|
|
</span>
|
|
"""
|
|
end
|
|
|
|
defp format_date(datetime) do
|
|
Calendar.strftime(datetime, "%d %b %Y %H:%M")
|
|
end
|
|
end
|