2026-02-18 21:23:15 +00:00
|
|
|
defmodule BerrypodWeb.Admin.Dashboard do
|
|
|
|
|
use BerrypodWeb, :live_view
|
2026-02-12 14:17:38 +00:00
|
|
|
|
2026-02-18 23:55:42 +00:00
|
|
|
alias Berrypod.{Cart, Orders, Products, Settings}
|
2026-02-12 14:17:38 +00:00
|
|
|
|
|
|
|
|
@impl true
|
|
|
|
|
def mount(_params, _session, socket) do
|
2026-02-18 23:55:42 +00:00
|
|
|
if Settings.site_live?() do
|
|
|
|
|
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(:paid_count, paid_count)
|
|
|
|
|
|> assign(:revenue, Orders.total_revenue())
|
|
|
|
|
|> assign(:product_count, Products.count_products())
|
|
|
|
|
|> assign(:recent_orders, recent_orders)}
|
2026-02-12 22:55:29 +00:00
|
|
|
else
|
2026-02-18 23:55:42 +00:00
|
|
|
{:ok, push_navigate(socket, to: ~p"/admin/setup")}
|
2026-02-12 22:55:29 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-12 14:17:38 +00:00
|
|
|
@impl true
|
|
|
|
|
def render(assigns) do
|
|
|
|
|
~H"""
|
|
|
|
|
<.header>
|
|
|
|
|
Dashboard
|
|
|
|
|
</.header>
|
|
|
|
|
|
|
|
|
|
<%!-- 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"
|
2026-02-18 23:55:42 +00:00
|
|
|
value={@product_count}
|
2026-02-12 14:17:38 +00:00
|
|
|
icon="hero-cube"
|
2026-02-16 08:48:51 +00:00
|
|
|
href={~p"/admin/products"}
|
2026-02-12 14:17:38 +00:00
|
|
|
/>
|
|
|
|
|
</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>
|
2026-02-12 22:55:29 +00:00
|
|
|
<.link
|
|
|
|
|
navigate={~p"/admin/orders"}
|
|
|
|
|
class="text-sm text-base-content/60 hover:text-base-content"
|
|
|
|
|
>
|
2026-02-12 14:17:38 +00:00
|
|
|
View all →
|
|
|
|
|
</.link>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<%= if @recent_orders == [] do %>
|
2026-02-12 22:55:29 +00:00
|
|
|
<div class="rounded-lg border border-base-200 p-8 text-center text-base-content/60">
|
|
|
|
|
<.icon name="hero-inbox" class="size-10 mx-auto mb-3 text-base-content/30" />
|
2026-02-12 14:17:38 +00:00
|
|
|
<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>
|
2026-02-12 22:55:29 +00:00
|
|
|
<tr class="border-b border-base-200 text-left text-base-content/60">
|
2026-02-12 14:17:38 +00:00
|
|
|
<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}
|
2026-02-12 22:55:29 +00:00
|
|
|
class="border-b border-base-200 hover:bg-base-200/50 cursor-pointer"
|
2026-02-12 14:17:38 +00:00
|
|
|
phx-click={JS.navigate(~p"/admin/orders/#{order}")}
|
|
|
|
|
>
|
|
|
|
|
<td class="py-2.5 font-medium">{order.order_number}</td>
|
2026-02-12 22:55:29 +00:00
|
|
|
<td class="py-2.5 text-base-content/60">{format_date(order.inserted_at)}</td>
|
|
|
|
|
<td class="py-2.5 text-base-content/60">{order.customer_email || "—"}</td>
|
2026-02-12 14:17:38 +00:00
|
|
|
<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
|
|
|
|
|
|
2026-02-12 22:55:29 +00:00
|
|
|
# ==========================================================================
|
2026-02-18 23:55:42 +00:00
|
|
|
# Components
|
2026-02-12 22:55:29 +00:00
|
|
|
# ==========================================================================
|
|
|
|
|
|
2026-02-12 14:17:38 +00:00
|
|
|
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}
|
2026-02-12 22:55:29 +00:00
|
|
|
class="rounded-lg border border-base-200 p-4 hover:border-base-300 transition-colors"
|
2026-02-12 14:17:38 +00:00
|
|
|
>
|
|
|
|
|
<div class="flex items-center gap-3">
|
2026-02-12 22:55:29 +00:00
|
|
|
<div class="rounded-lg bg-base-200 p-2">
|
|
|
|
|
<.icon name={@icon} class="size-5 text-base-content/60" />
|
2026-02-12 14:17:38 +00:00
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-2xl font-bold">{@value}</p>
|
2026-02-12 22:55:29 +00:00
|
|
|
<p class="text-sm text-base-content/60">{@label}</p>
|
2026-02-12 14:17:38 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</.link>
|
|
|
|
|
"""
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp fulfilment_pill(assigns) do
|
|
|
|
|
{color, label} =
|
|
|
|
|
case assigns.status do
|
2026-02-12 22:55:29 +00:00
|
|
|
"unfulfilled" -> {"bg-base-200 text-base-content/60", "unfulfilled"}
|
2026-02-12 14:17:38 +00:00
|
|
|
"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"}
|
2026-02-12 22:55:29 +00:00
|
|
|
_ -> {"bg-base-200 text-base-content/60", assigns.status || "—"}
|
2026-02-12 14:17:38 +00:00
|
|
|
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
|
|
|
|
|
|
2026-02-12 22:55:29 +00:00
|
|
|
# ==========================================================================
|
|
|
|
|
# Helpers
|
|
|
|
|
# ==========================================================================
|
|
|
|
|
|
2026-02-12 14:17:38 +00:00
|
|
|
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
|