feat: add order management admin with list and detail views

Admin UI at /admin/orders to view, filter, and inspect orders.
Adds list_orders/1 and count_orders_by_status/0 to the Orders
context, status filter tabs, clickable order table with streams,
and a detail page showing items, totals, and shipping address.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-07 21:59:14 +00:00
parent e6f8d7fa2a
commit 02cdc810f2
8 changed files with 603 additions and 0 deletions

View File

@ -10,6 +10,41 @@ defmodule SimpleshopTheme.Orders do
alias SimpleshopTheme.Repo alias SimpleshopTheme.Repo
alias SimpleshopTheme.Orders.{Order, OrderItem} alias SimpleshopTheme.Orders.{Order, OrderItem}
@doc """
Lists orders, optionally filtered by payment status.
## Options
* `:status` - filter by payment_status ("paid", "pending", "failed", "refunded")
Pass nil or "all" to return all orders.
Returns orders sorted by newest first, with items preloaded.
"""
def list_orders(opts \\ []) do
status = opts[:status]
Order
|> maybe_filter_status(status)
|> order_by([o], desc: o.inserted_at)
|> preload(:items)
|> Repo.all()
end
defp maybe_filter_status(query, nil), do: query
defp maybe_filter_status(query, "all"), do: query
defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status)
@doc """
Returns a map of payment_status => count for all orders.
"""
def count_orders_by_status do
Order
|> group_by(:payment_status)
|> select([o], {o.payment_status, count(o.id)})
|> Repo.all()
|> Map.new()
end
@doc """ @doc """
Creates an order with line items from hydrated cart data. Creates an order with line items from hydrated cart data.

View File

@ -45,6 +45,9 @@
<li> <li>
<.link href={~p"/admin/theme"}>Theme</.link> <.link href={~p"/admin/theme"}>Theme</.link>
</li> </li>
<li>
<.link href={~p"/admin/orders"}>Orders</.link>
</li>
<li> <li>
<.link href={~p"/admin/settings"}>Credentials</.link> <.link href={~p"/admin/settings"}>Credentials</.link>
</li> </li>

View File

@ -0,0 +1,175 @@
defmodule SimpleshopThemeWeb.AdminLive.OrderShow do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Orders
alias SimpleshopTheme.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"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
<.link
navigate={~p"/admin/orders"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
&larr; 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="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body">
<h3 class="card-title text-base">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="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body">
<h3 class="card-title text-base">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"]} 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>
<%!-- line items --%>
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
<div class="card-body">
<h3 class="card-title text-base">Items</h3>
<table class="table 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>
</Layouts.app>
"""
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-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-arrow-uturn-left-mini"}
_ ->
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/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

View File

@ -0,0 +1,162 @@
defmodule SimpleshopThemeWeb.AdminLive.Orders do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Cart
@impl true
def mount(_params, _session, socket) do
counts = Orders.count_orders_by_status()
orders = Orders.list_orders()
socket =
socket
|> assign(:page_title, "Orders")
|> assign(:status_filter, "all")
|> assign(:status_counts, counts)
|> assign(:order_count, length(orders))
|> stream(:orders, orders)
{:ok, socket}
end
@impl true
def handle_event("filter", %{"status" => status}, socket) do
orders = Orders.list_orders(status: status)
socket =
socket
|> assign(:status_filter, status)
|> assign(:order_count, length(orders))
|> stream(:orders, orders, reset: true)
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Orders
</.header>
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
<.filter_tab
status="all"
label="All"
count={total_count(@status_counts)}
active={@status_filter}
/>
<.filter_tab
status="paid"
label="Paid"
count={@status_counts["paid"]}
active={@status_filter}
/>
<.filter_tab
status="pending"
label="Pending"
count={@status_counts["pending"]}
active={@status_filter}
/>
<.filter_tab
status="failed"
label="Failed"
count={@status_counts["failed"]}
active={@status_filter}
/>
<.filter_tab
status="refunded"
label="Refunded"
count={@status_counts["refunded"]}
active={@status_filter}
/>
</div>
<.table
:if={@order_count > 0}
id="orders"
rows={@streams.orders}
row_item={fn {_id, order} -> order end}
row_click={fn {_id, order} -> JS.navigate(~p"/admin/orders/#{order}") end}
>
<:col :let={order} label="Order">{order.order_number}</:col>
<:col :let={order} label="Date">{format_date(order.inserted_at)}</:col>
<:col :let={order} label="Customer">{order.customer_email || ""}</:col>
<:col :let={order} label="Total">{Cart.format_price(order.total)}</:col>
<:col :let={order} label="Status"><.status_badge status={order.payment_status} /></:col>
</.table>
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-inbox" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No orders yet</p>
<p class="text-sm mt-1">Orders will appear here once customers check out.</p>
</div>
</Layouts.app>
"""
end
defp filter_tab(assigns) do
count = assigns[:count] || 0
active = assigns.active == assigns.status
assigns = assign(assigns, count: count, active: active)
~H"""
<button
phx-click="filter"
phx-value-status={@status}
class={[
"btn btn-sm",
@active && "btn-primary",
!@active && "btn-ghost"
]}
>
{@label}
<span :if={@count > 0} class="badge badge-sm ml-1">{@count}</span>
</button>
"""
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-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-arrow-uturn-left-mini"}
_ ->
{"bg-zinc-50", "text-zinc-600", "ring-zinc-500/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
defp total_count(counts) do
counts |> Map.values() |> Enum.sum()
end
end

View File

@ -117,6 +117,8 @@ defmodule SimpleshopThemeWeb.Router do
live "/admin/providers", ProviderLive.Index, :index live "/admin/providers", ProviderLive.Index, :index
live "/admin/providers/new", ProviderLive.Form, :new live "/admin/providers/new", ProviderLive.Form, :new
live "/admin/providers/:id/edit", ProviderLive.Form, :edit live "/admin/providers/:id/edit", ProviderLive.Form, :edit
live "/admin/orders", AdminLive.Orders, :index
live "/admin/orders/:id", AdminLive.OrderShow, :show
live "/admin/settings", AdminLive.Settings, :index live "/admin/settings", AdminLive.Settings, :index
end end

View File

@ -0,0 +1,65 @@
defmodule SimpleshopTheme.OrdersTest do
use SimpleshopTheme.DataCase, async: false
alias SimpleshopTheme.Orders
import SimpleshopTheme.OrdersFixtures
describe "list_orders/1" do
test "returns all orders" do
order1 = order_fixture()
order2 = order_fixture()
orders = Orders.list_orders()
order_ids = Enum.map(orders, & &1.id)
assert order1.id in order_ids
assert order2.id in order_ids
assert length(orders) == 2
end
test "filters by payment status" do
_pending = order_fixture()
paid = order_fixture(payment_status: "paid")
_failed = order_fixture(payment_status: "failed")
orders = Orders.list_orders(status: "paid")
assert length(orders) == 1
assert hd(orders).id == paid.id
end
test "returns all when status is 'all'" do
order_fixture()
order_fixture(payment_status: "paid")
orders = Orders.list_orders(status: "all")
assert length(orders) == 2
end
test "preloads items" do
order_fixture()
[order] = Orders.list_orders()
assert Ecto.assoc_loaded?(order.items)
assert length(order.items) == 1
end
end
describe "count_orders_by_status/0" do
test "returns empty map when no orders" do
assert Orders.count_orders_by_status() == %{}
end
test "counts orders by status" do
order_fixture()
order_fixture()
order_fixture(payment_status: "paid")
order_fixture(payment_status: "failed")
counts = Orders.count_orders_by_status()
assert counts["pending"] == 2
assert counts["paid"] == 1
assert counts["failed"] == 1
end
end
end

View File

@ -0,0 +1,119 @@
defmodule SimpleshopThemeWeb.AdminLive.OrdersTest do
use SimpleshopThemeWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures
import SimpleshopTheme.OrdersFixtures
setup do
user = user_fixture()
%{user: user}
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin/orders")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "order list" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "renders empty state when no orders", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/orders")
assert html =~ "No orders yet"
assert html =~ "Orders"
end
test "renders orders table", %{conn: conn} do
order = order_fixture(payment_status: "paid", customer_email: "test@shop.com")
{:ok, _view, html} = live(conn, ~p"/admin/orders")
assert html =~ order.order_number
assert html =~ "test@shop.com"
assert html =~ "paid"
end
test "filters by status", %{conn: conn} do
paid = order_fixture(payment_status: "paid")
_pending = order_fixture()
{:ok, view, _html} = live(conn, ~p"/admin/orders")
html = render_click(view, "filter", %{"status" => "paid"})
assert html =~ paid.order_number
end
test "navigates to order detail", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, _view, html} = live(conn, ~p"/admin/orders")
assert html =~ ~p"/admin/orders/#{order}"
end
end
describe "order detail" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "renders order details", %{conn: conn} do
order = order_fixture(payment_status: "paid", customer_email: "buyer@example.com")
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ order.order_number
assert html =~ "buyer@example.com"
assert html =~ "paid"
assert html =~ "Order details"
end
test "shows line items", %{conn: conn} do
order = order_fixture(product_name: "Cool T-shirt", variant_title: "Blue / XL")
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Cool T-shirt"
assert html =~ "Blue / XL"
end
test "shows shipping address", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, updated_order} =
SimpleshopTheme.Orders.update_order(order, %{
shipping_address: %{
"name" => "Jane Doe",
"line1" => "42 Test Street",
"city" => "London",
"postal_code" => "SW1A 1AA",
"country" => "GB"
}
})
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{updated_order}")
assert html =~ "Jane Doe"
assert html =~ "42 Test Street"
assert html =~ "London"
assert html =~ "SW1A 1AA"
end
test "redirects when order not found", %{conn: conn} do
fake_id = Ecto.UUID.generate()
{:error, {:live_redirect, %{to: "/admin/orders"}}} =
live(conn, ~p"/admin/orders/#{fake_id}")
end
end
end

View File

@ -0,0 +1,42 @@
defmodule SimpleshopTheme.OrdersFixtures do
@moduledoc """
Test helpers for creating orders.
"""
alias SimpleshopTheme.Orders
def order_fixture(attrs \\ %{}) do
attrs = Enum.into(attrs, %{})
items = [
%{
variant_id: "var_#{System.unique_integer([:positive])}",
name: Map.get(attrs, :product_name, "Test product"),
variant: Map.get(attrs, :variant_title, "Red / Large"),
price: Map.get(attrs, :unit_price, 1999),
quantity: Map.get(attrs, :quantity, 1)
}
]
order_attrs = %{
items: items,
customer_email: Map.get(attrs, :customer_email, "customer@example.com"),
currency: Map.get(attrs, :currency, "gbp")
}
{:ok, order} = Orders.create_order(order_attrs)
case attrs[:payment_status] do
"paid" ->
{:ok, order} = Orders.mark_paid(order, "pi_test_#{System.unique_integer([:positive])}")
order
"failed" ->
{:ok, order} = Orders.mark_failed(order)
order
_ ->
order
end
end
end