add admin dashboard with setup checklist and stats

Dashboard at /admin shows setup progress (when not live), stat cards
(orders, revenue, products), and recent paid orders table. Replaces
the old AdminController redirect. Add Dashboard to sidebar nav as
first item, update admin bar and theme editor links to /admin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-12 14:17:38 +00:00
parent 4514608c07
commit 0dac93ec0b
10 changed files with 321 additions and 21 deletions

View File

@ -37,15 +37,14 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc
| ~~9~~ | ~~`Setup.setup_status/0` helper~~ | 2, 3 | 30m | done | | ~~9~~ | ~~`Setup.setup_status/0` helper~~ | 2, 3 | 30m | done |
| ~~10~~ | ~~ThemeHook gate (redirect to coming soon)~~ | 2, 8 | 30m | done | | ~~10~~ | ~~ThemeHook gate (redirect to coming soon)~~ | 2, 8 | 30m | done |
| ~~14~~ | ~~Go live / take offline toggle (on settings page)~~ | 2 | 30m | done | | ~~14~~ | ~~Go live / take offline toggle (on settings page)~~ | 2 | 30m | done |
| | **Admin shell chain (priority)** | | | | | ~~1~~ | ~~Filesystem restructure (consolidate live/ directories)~~ | — | 2h | done |
| 1 | Filesystem restructure (consolidate live/ directories) | — | 2h | | | ~~6~~ | ~~Admin shell component (sidebar nav, header)~~ | 1 | 2-3h | done |
| 6 | Admin shell component (sidebar nav, header) | 1 | 2-3h | | | ~~7~~ | ~~Admin root + child layout templates~~ | 1 | 1h | done |
| 7 | Admin root + child layout templates | 1 | 1h | | | ~~11~~ | ~~Theme editor back-to-admin link~~ | 6 | 30m | done |
| 11 | Theme editor back-to-admin link | 6 | 30m | | | ~~4~~ | ~~Admin bar on shop pages~~ | — | 1h | done |
| 12 | Consolidate settings page | 6, 7 | 2-3h | | | ~~12~~ | ~~Consolidate settings page~~ | 6, 7 | 2-3h | done |
| | **Next up** | | | |
| 13 | Admin dashboard (+ setup checklist) | 6, 7, 9 | 2h | | | 13 | Admin dashboard (+ setup checklist) | 6, 7, 9 | 2h | |
| | **Independent** | | | |
| 4 | Admin bar on shop pages | — | 1h | |
| 5 | Search (functional search with results) | — | 3-4h | | | 5 | Search (functional search with results) | — | 3-4h | |
| | **Needs admin stable** | | | | | | **Needs admin stable** | | | |
| 15 | Setup wizard + admin tests | 13 | 1.5h | | | 15 | Setup wizard + admin tests | 13 | 1.5h | |

View File

@ -50,6 +50,16 @@ defmodule SimpleshopTheme.Orders do
|> Map.new() |> Map.new()
end end
@doc """
Returns total revenue (in minor units) from paid orders.
"""
def total_revenue do
Order
|> where(payment_status: "paid")
|> select([o], sum(o.total))
|> Repo.one() || 0
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

@ -46,6 +46,10 @@ defmodule SimpleshopThemeWeb.Layouts do
end end
@doc false @doc false
def admin_nav_active?(current_path, "/admin") do
if current_path == "/admin", do: "active", else: nil
end
def admin_nav_active?(current_path, link_path) do def admin_nav_active?(current_path, link_path) do
if String.starts_with?(current_path, link_path), do: "active", else: nil if String.starts_with?(current_path, link_path), do: "active", else: nil
end end

View File

@ -34,7 +34,7 @@
<aside class="bg-base-200 w-64 min-h-full flex flex-col"> <aside class="bg-base-200 w-64 min-h-full flex flex-col">
<%!-- sidebar header --%> <%!-- sidebar header --%>
<div class="p-4 border-b border-base-300"> <div class="p-4 border-b border-base-300">
<.link navigate={~p"/admin/orders"} class="text-lg font-bold tracking-tight"> <.link navigate={~p"/admin"} class="text-lg font-bold tracking-tight">
SimpleShop SimpleShop
</.link> </.link>
<p class="text-xs text-base-content/60 mt-0.5 truncate"> <p class="text-xs text-base-content/60 mt-0.5 truncate">
@ -45,6 +45,14 @@
<%!-- nav links --%> <%!-- nav links --%>
<nav class="flex-1 p-2" aria-label="Admin navigation"> <nav class="flex-1 p-2" aria-label="Admin navigation">
<ul class="menu gap-0.5"> <ul class="menu gap-0.5">
<li>
<.link
navigate={~p"/admin"}
class={admin_nav_active?(@current_path, "/admin")}
>
<.icon name="hero-home" class="size-5" /> Dashboard
</.link>
</li>
<li> <li>
<.link <.link
navigate={~p"/admin/orders"} navigate={~p"/admin/orders"}

View File

@ -3,7 +3,7 @@
style="background-color: var(--t-surface-raised, #f5f5f5); border-bottom: 1px solid var(--t-border-default, #e5e5e5); padding: 0.25rem 1rem; font-size: 0.75rem; text-align: right;" style="background-color: var(--t-surface-raised, #f5f5f5); border-bottom: 1px solid var(--t-border-default, #e5e5e5); padding: 0.25rem 1rem; font-size: 0.75rem; text-align: right;"
> >
<.link <.link
href={~p"/admin/orders"} href={~p"/admin"}
style="color: var(--t-text-secondary, #666); text-decoration: none;" style="color: var(--t-text-secondary, #666); text-decoration: none;"
> >
Admin Admin

View File

@ -0,0 +1,207 @@
defmodule SimpleshopThemeWeb.Admin.Dashboard do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.{Cart, Orders, Setup}
@impl true
def mount(_params, _session, socket) do
status = Setup.setup_status()
status_counts = Orders.count_orders_by_status()
paid_count = Map.get(status_counts, "paid", 0)
recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5)
{:ok,
socket
|> assign(:page_title, "Dashboard")
|> assign(:setup, status)
|> assign(:paid_count, paid_count)
|> assign(:revenue, Orders.total_revenue())
|> assign(:recent_orders, recent_orders)}
end
@impl true
def render(assigns) do
~H"""
<.header>
Dashboard
</.header>
<%!-- Setup checklist (when not fully set up) --%>
<.setup_checklist :if={!@setup.site_live} setup={@setup} />
<%!-- Stats --%>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
<.stat_card
label="Orders"
value={@paid_count}
icon="hero-shopping-bag"
href={~p"/admin/orders"}
/>
<.stat_card
label="Revenue"
value={format_revenue(@revenue)}
icon="hero-banknotes"
href={~p"/admin/orders"}
/>
<.stat_card
label="Products"
value={@setup.product_count}
icon="hero-cube"
href={~p"/admin/settings"}
/>
</div>
<%!-- Recent orders --%>
<section class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Recent orders</h2>
<.link navigate={~p"/admin/orders"} class="text-sm text-zinc-500 hover:text-zinc-700">
View all &rarr;
</.link>
</div>
<%= if @recent_orders == [] do %>
<div class="rounded-lg border border-zinc-200 p-8 text-center text-zinc-500">
<.icon name="hero-inbox" class="size-10 mx-auto mb-3 text-zinc-300" />
<p class="font-medium">No orders yet</p>
<p class="text-sm mt-1">Orders will appear here once customers check out.</p>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-zinc-200 text-left text-zinc-500">
<th class="pb-2 font-medium">Order</th>
<th class="pb-2 font-medium">Date</th>
<th class="pb-2 font-medium">Customer</th>
<th class="pb-2 font-medium text-right">Total</th>
<th class="pb-2 font-medium">Fulfilment</th>
</tr>
</thead>
<tbody>
<tr
:for={order <- @recent_orders}
class="border-b border-zinc-100 hover:bg-zinc-50 cursor-pointer"
phx-click={JS.navigate(~p"/admin/orders/#{order}")}
>
<td class="py-2.5 font-medium">{order.order_number}</td>
<td class="py-2.5 text-zinc-600">{format_date(order.inserted_at)}</td>
<td class="py-2.5 text-zinc-600">{order.customer_email || ""}</td>
<td class="py-2.5 text-right">{Cart.format_price(order.total)}</td>
<td class="py-2.5"><.fulfilment_pill status={order.fulfilment_status} /></td>
</tr>
</tbody>
</table>
</div>
<% end %>
</section>
"""
end
# -- Function components --
attr :setup, :map, required: true
defp setup_checklist(assigns) do
steps = [
%{done: assigns.setup.admin_created, label: "Create admin account", href: nil},
%{
done: assigns.setup.printify_connected,
label: "Connect to Printify",
href: ~p"/admin/providers/new"
},
%{done: assigns.setup.products_synced, label: "Sync products", href: nil},
%{done: assigns.setup.stripe_connected, label: "Connect Stripe", href: ~p"/admin/settings"},
%{done: assigns.setup.site_live, label: "Go live", href: ~p"/admin/settings"}
]
done_count = Enum.count(steps, & &1.done)
assigns = assign(assigns, steps: steps, done_count: done_count, total: length(steps))
~H"""
<div class="mt-6 rounded-lg border border-zinc-200 p-5">
<div class="flex items-center justify-between mb-3">
<h2 class="font-semibold">Setup progress</h2>
<span class="text-sm text-zinc-500">{@done_count}/{@total}</span>
</div>
<div class="w-full bg-zinc-100 rounded-full h-2 mb-4">
<div
class="bg-green-500 h-2 rounded-full transition-all"
style={"width: #{@done_count / @total * 100}%"}
>
</div>
</div>
<ul class="space-y-2">
<li :for={step <- @steps} class="flex items-center gap-2 text-sm">
<%= if step.done do %>
<.icon name="hero-check-circle" class="size-5 text-green-500 shrink-0" />
<span class="text-zinc-500 line-through">{step.label}</span>
<% else %>
<.icon name="hero-circle-stack" class="size-5 text-zinc-300 shrink-0" />
<%= if step.href do %>
<.link navigate={step.href} class="text-zinc-700 hover:underline">{step.label}</.link>
<% else %>
<span class="text-zinc-700">{step.label}</span>
<% end %>
<% end %>
</li>
</ul>
</div>
"""
end
attr :label, :string, required: true
attr :value, :any, required: true
attr :icon, :string, required: true
attr :href, :string, required: true
defp stat_card(assigns) do
~H"""
<.link
navigate={@href}
class="rounded-lg border border-zinc-200 p-4 hover:border-zinc-300 transition-colors"
>
<div class="flex items-center gap-3">
<div class="rounded-lg bg-zinc-100 p-2">
<.icon name={@icon} class="size-5 text-zinc-600" />
</div>
<div>
<p class="text-2xl font-bold">{@value}</p>
<p class="text-sm text-zinc-500">{@label}</p>
</div>
</div>
</.link>
"""
end
defp fulfilment_pill(assigns) do
{color, label} =
case assigns.status do
"unfulfilled" -> {"bg-zinc-100 text-zinc-600", "unfulfilled"}
"submitted" -> {"bg-blue-50 text-blue-700", "submitted"}
"processing" -> {"bg-amber-50 text-amber-700", "processing"}
"shipped" -> {"bg-purple-50 text-purple-700", "shipped"}
"delivered" -> {"bg-green-50 text-green-700", "delivered"}
"failed" -> {"bg-red-50 text-red-700", "failed"}
_ -> {"bg-zinc-100 text-zinc-600", assigns.status || ""}
end
assigns = assign(assigns, color: color, label: label)
~H"""
<span class={["inline-flex rounded-full px-2 py-0.5 text-xs font-medium", @color]}>
{@label}
</span>
"""
end
defp format_revenue(amount_pence) when is_integer(amount_pence) do
Cart.format_price(amount_pence)
end
defp format_revenue(_), do: "£0.00"
defp format_date(datetime) do
Calendar.strftime(datetime, "%d %b %Y")
end
end

View File

@ -36,7 +36,7 @@
</div> </div>
<% else %> <% else %>
<.link <.link
href={~p"/admin/orders"} href={~p"/admin"}
class="inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-base-content mb-4" class="inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-base-content mb-4"
> >
<.icon name="hero-arrow-left-mini" class="size-4" /> Admin <.icon name="hero-arrow-left-mini" class="size-4" /> Admin

View File

@ -128,13 +128,6 @@ defmodule SimpleshopThemeWeb.Router do
## Authentication routes ## Authentication routes
# /admin index redirect
scope "/admin", SimpleshopThemeWeb do
pipe_through [:browser, :require_authenticated_user]
get "/", AdminController, :index
end
# Admin pages with sidebar layout # Admin pages with sidebar layout
scope "/admin", SimpleshopThemeWeb do scope "/admin", SimpleshopThemeWeb do
pipe_through [:browser, :require_authenticated_user, :admin] pipe_through [:browser, :require_authenticated_user, :admin]
@ -145,6 +138,7 @@ defmodule SimpleshopThemeWeb.Router do
{SimpleshopThemeWeb.UserAuth, :require_authenticated}, {SimpleshopThemeWeb.UserAuth, :require_authenticated},
{SimpleshopThemeWeb.AdminLayoutHook, :assign_current_path} {SimpleshopThemeWeb.AdminLayoutHook, :assign_current_path}
] do ] do
live "/", Admin.Dashboard, :index
live "/orders", Admin.Orders, :index live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show live "/orders/:id", Admin.OrderShow, :show
live "/providers", Admin.Providers.Index, :index live "/providers", Admin.Providers.Index, :index

View File

@ -0,0 +1,70 @@
defmodule SimpleshopThemeWeb.Admin.DashboardTest 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")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "setup checklist" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "shows setup progress when shop is offline", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin")
assert html =~ "Setup progress"
assert html =~ "Create admin account"
assert html =~ "Connect to Printify"
assert html =~ "Connect Stripe"
assert html =~ "Go live"
end
test "hides setup checklist when shop is live", %{conn: conn} do
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
{:ok, _view, html} = live(conn, ~p"/admin")
refute html =~ "Setup progress"
end
end
describe "stats" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "shows stats cards", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin")
assert has_element?(view, "a[href='/admin/orders']", "Orders")
assert has_element?(view, "a[href='/admin/settings']", "Products")
end
test "shows zero state for orders", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin")
assert html =~ "No orders yet"
end
test "shows recent orders when they exist", %{conn: conn} do
order = order_fixture(%{payment_status: "paid"})
{:ok, _view, html} = live(conn, ~p"/admin")
assert html =~ order.order_number
assert html =~ "Recent orders"
end
end
end

View File

@ -17,6 +17,7 @@ defmodule SimpleshopThemeWeb.Admin.LayoutTest do
test "renders sidebar nav links on admin pages", %{conn: conn} do test "renders sidebar nav links on admin pages", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/orders") {:ok, view, _html} = live(conn, ~p"/admin/orders")
assert has_element?(view, ~s(a[href="/admin"]), "Dashboard")
assert has_element?(view, ~s(a[href="/admin/orders"]), "Orders") assert has_element?(view, ~s(a[href="/admin/orders"]), "Orders")
assert has_element?(view, ~s(a[href="/admin/theme"]), "Theme") assert has_element?(view, ~s(a[href="/admin/theme"]), "Theme")
assert has_element?(view, ~s(a[href="/admin/settings"]), "Settings") assert has_element?(view, ~s(a[href="/admin/settings"]), "Settings")
@ -29,6 +30,13 @@ defmodule SimpleshopThemeWeb.Admin.LayoutTest do
refute has_element?(view, ~s(a.active[href="/admin/settings"])) refute has_element?(view, ~s(a.active[href="/admin/settings"]))
end end
test "highlights dashboard on dashboard page", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin")
assert has_element?(view, ~s(a.active[href="/admin"]))
refute has_element?(view, ~s(a.active[href="/admin/orders"]))
end
test "highlights correct link on different pages", %{conn: conn} do test "highlights correct link on different pages", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings") {:ok, view, _html} = live(conn, ~p"/admin/settings")
@ -64,7 +72,7 @@ defmodule SimpleshopThemeWeb.Admin.LayoutTest do
test "shows back link to admin", %{conn: conn} do test "shows back link to admin", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme") {:ok, view, _html} = live(conn, ~p"/admin/theme")
assert has_element?(view, ~s(a[href="/admin/orders"]), "Admin") assert has_element?(view, ~s(a[href="/admin"]), "Admin")
end end
end end
@ -78,13 +86,13 @@ defmodule SimpleshopThemeWeb.Admin.LayoutTest do
conn = log_in_user(conn, user) conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/") {:ok, _view, html} = live(conn, ~p"/")
assert html =~ ~s(href="/admin/orders") assert html =~ ~s(href="/admin")
end end
test "does not show admin link when logged out", %{conn: conn} do test "does not show admin link when logged out", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/") {:ok, _view, html} = live(conn, ~p"/")
refute html =~ ~s(href="/admin/orders") refute html =~ ~s(href="/admin")
end end
end end
end end