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

@@ -50,6 +50,16 @@ defmodule SimpleshopTheme.Orders do
|> Map.new()
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 """
Creates an order with line items from hydrated cart data.

View File

@@ -46,6 +46,10 @@ defmodule SimpleshopThemeWeb.Layouts do
end
@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
if String.starts_with?(current_path, link_path), do: "active", else: nil
end

View File

@@ -34,7 +34,7 @@
<aside class="bg-base-200 w-64 min-h-full flex flex-col">
<%!-- sidebar header --%>
<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
</.link>
<p class="text-xs text-base-content/60 mt-0.5 truncate">
@@ -45,6 +45,14 @@
<%!-- nav links --%>
<nav class="flex-1 p-2" aria-label="Admin navigation">
<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>
<.link
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;"
>
<.link
href={~p"/admin/orders"}
href={~p"/admin"}
style="color: var(--t-text-secondary, #666); text-decoration: none;"
>
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>
<% else %>
<.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"
>
<.icon name="hero-arrow-left-mini" class="size-4" /> Admin

View File

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