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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user