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:
parent
4514608c07
commit
0dac93ec0b
15
PROGRESS.md
15
PROGRESS.md
@ -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 |
|
||||
| ~~10~~ | ~~ThemeHook gate (redirect to coming soon)~~ | 2, 8 | 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 | |
|
||||
| 6 | Admin shell component (sidebar nav, header) | 1 | 2-3h | |
|
||||
| 7 | Admin root + child layout templates | 1 | 1h | |
|
||||
| 11 | Theme editor back-to-admin link | 6 | 30m | |
|
||||
| 12 | Consolidate settings page | 6, 7 | 2-3h | |
|
||||
| ~~1~~ | ~~Filesystem restructure (consolidate live/ directories)~~ | — | 2h | done |
|
||||
| ~~6~~ | ~~Admin shell component (sidebar nav, header)~~ | 1 | 2-3h | done |
|
||||
| ~~7~~ | ~~Admin root + child layout templates~~ | 1 | 1h | done |
|
||||
| ~~11~~ | ~~Theme editor back-to-admin link~~ | 6 | 30m | done |
|
||||
| ~~4~~ | ~~Admin bar on shop pages~~ | — | 1h | done |
|
||||
| ~~12~~ | ~~Consolidate settings page~~ | 6, 7 | 2-3h | done |
|
||||
| | **Next up** | | | |
|
||||
| 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 | |
|
||||
| | **Needs admin stable** | | | |
|
||||
| 15 | Setup wizard + admin tests | 13 | 1.5h | |
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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
|
||||
|
||||
207
lib/simpleshop_theme_web/live/admin/dashboard.ex
Normal file
207
lib/simpleshop_theme_web/live/admin/dashboard.ex
Normal 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 →
|
||||
</.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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
70
test/simpleshop_theme_web/live/admin/dashboard_test.exs
Normal file
70
test/simpleshop_theme_web/live/admin/dashboard_test.exs
Normal 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
|
||||
@ -17,6 +17,7 @@ defmodule SimpleshopThemeWeb.Admin.LayoutTest do
|
||||
test "renders sidebar nav links on admin pages", %{conn: conn} do
|
||||
{: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/theme"]), "Theme")
|
||||
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"]))
|
||||
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
|
||||
{: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
|
||||
{: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
|
||||
|
||||
@ -78,13 +86,13 @@ defmodule SimpleshopThemeWeb.Admin.LayoutTest do
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
assert html =~ ~s(href="/admin/orders")
|
||||
assert html =~ ~s(href="/admin")
|
||||
end
|
||||
|
||||
test "does not show admin link when logged out", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
refute html =~ ~s(href="/admin/orders")
|
||||
refute html =~ ~s(href="/admin")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Loading…
Reference in New Issue
Block a user