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>
208 lines
6.9 KiB
Elixir
208 lines
6.9 KiB
Elixir
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
|