feat(admin): add v2 dashboard with stat cards and today's stops

This commit is contained in:
Tyler
2026-06-17 15:19:32 -06:00
parent d913ca194e
commit afd96b93e1
2 changed files with 441 additions and 0 deletions
+66
View File
@@ -28,6 +28,30 @@ export type DashboardSummary = {
active_products: number;
};
/**
* Mobile-dashboard summary shape. Returned by the `get_dashboard_summary`
* RPC (migration 0091) and consumed by `/admin/v2` to power the four
* stat cards + the 7-day order chart.
*
* - `revenue_today` is in CENTS (not dollars) — divide by 100 for display.
* - `orders_last_7_days` is oldest → today (7 elements, includes today).
*/
export type MobileDashboardSummary = {
orders_today: number;
revenue_today: number;
pending_fulfillment: number;
stops_today: number;
orders_last_7_days: Array<{ date: string; count: number }>;
};
const EMPTY_MOBILE_DASHBOARD: MobileDashboardSummary = {
orders_today: 0,
revenue_today: 0,
pending_fulfillment: 0,
stops_today: 0,
orders_last_7_days: [],
};
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
export async function getDashboardStats(): Promise<DashboardStats> {
@@ -276,6 +300,48 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
}
}
/**
* Fetch the mobile dashboard summary for a single brand.
*
* Different name from the existing `getDashboardSummary()` (which is the
* legacy 4-field shape used by the v1 dashboard) so both can coexist.
* Returns safe fallbacks on any error — the v2 page renders a 0s
* dashboard rather than crashing the shell.
*/
export async function getMobileDashboardSummary(
brandId: string,
): Promise<MobileDashboardSummary> {
try {
const adminUser = await getAdminUser();
if (!adminUser) {
return EMPTY_MOBILE_DASHBOARD;
}
const { rows } = await pool.query<{ data: MobileDashboardSummary }>(
"SELECT get_dashboard_summary($1::uuid) AS data",
[brandId],
);
const data = rows[0]?.data;
if (!data) {
return EMPTY_MOBILE_DASHBOARD;
}
return {
orders_today: Number(data.orders_today ?? 0),
revenue_today: Number(data.revenue_today ?? 0),
pending_fulfillment: Number(data.pending_fulfillment ?? 0),
stops_today: Number(data.stops_today ?? 0),
orders_last_7_days: Array.isArray(data.orders_last_7_days)
? data.orders_last_7_days.map((d) => ({
date: String(d?.date ?? ""),
count: Number(d?.count ?? 0),
}))
: [],
};
} catch (error) {
console.error("Failed to fetch mobile dashboard summary:", error);
return EMPTY_MOBILE_DASHBOARD;
}
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function formatTimeAgo(dateString: string): string {
+375
View File
@@ -0,0 +1,375 @@
import Link from "next/link";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getMobileDashboardSummary } from "@/actions/dashboard";
import { getAdminOrders } from "@/actions/orders";
import PageHeader from "@/components/admin/PageHeader";
import EmptyState from "@/components/admin/EmptyState";
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
import PullToRefresh from "@/components/admin/PullToRefresh";
import { formatDate, formatRelativeTime } from "@/lib/format-date";
export const dynamic = "force-dynamic";
/**
* Row shape for the "Today's stops" inline query. Slimmer than the
* full v2 stops page shape — the dashboard only needs the time + a
* displayable address. The query mirrors `src/app/admin/v2/stops/page.tsx`'s
* `s.date = $1` pattern (stops uses a `date` DATE column, not
* `scheduled_at`).
*/
interface DashboardStopRow {
id: string;
time: string | null;
location: string;
address: string | null;
city: string | null;
state: string | null;
brand_id: string;
}
/**
* Map the legacy order status onto the StatusPill vocabulary. Mirrors
* the v2 orders page's `statusKind()` — `'pending' | 'confirmed'` is
* the v1 schema's "needs attention" set (everything before fulfillment).
*/
function orderStatusKind(status: string): StatusKind {
if (status === "canceled") return "cancelled";
if (status === "fulfilled") return "picked-up";
if (status === "confirmed") return "ready";
return "placed";
}
/** Format a stop's free-form time string (24h or 12h) for display. */
function formatStopTime(time: string | null): string {
if (!time) return "—";
const t = time.trim();
if (/[ap]m$/i.test(t)) return t.toUpperCase();
const m = t.match(/^(\d{1,2}):(\d{2})$/);
if (m) {
let h = parseInt(m[1], 10);
const min = m[2];
const ampm = h >= 12 ? "PM" : "AM";
if (h === 0) h = 12;
else if (h > 12) h -= 12;
return `${h}:${min} ${ampm}`;
}
return t;
}
/** Pick the best address string we have on a stop row. */
function stopAddress(stop: DashboardStopRow): string {
if (stop.address) return stop.address;
if (stop.location) return stop.location;
const cityState = [stop.city, stop.state].filter(Boolean).join(", ");
return cityState || "Stop";
}
/**
* Resolve the "today" YYYY-MM-DD string in the server's local timezone.
* Kept in sync with `src/app/admin/v2/stops/page.tsx` so a dashboard
* click-through lands on the matching stops list.
*/
function todayDateStr(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
export default async function DashboardV2Page() {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const activeBrandId = await getActiveBrandId(adminUser);
if (!activeBrandId) {
return (
<main>
<PageHeader title="Today" subtitle={formatDate(new Date())} />
<PullToRefresh>
<EmptyState
title="No brand selected"
description="Select a brand to view its dashboard."
icon={<span aria-hidden="true">🌾</span>}
/>
</PullToRefresh>
</main>
);
}
const brandId = activeBrandId;
const dateStr = todayDateStr();
// Parallel fetch: stat summary (RPC), today's stops, and pending
// orders. The stops query mirrors the v2 stops page's pool pattern;
// `getAdminOrders` is the shared legacy RPC.
const [summary, stopsResult, ordersResult] = await Promise.all([
getMobileDashboardSummary(brandId),
pool.query<DashboardStopRow>(
`SELECT s.id, s."time", s.location, s.address, s.city, s.state, s.brand_id
FROM stops s
WHERE s.date = $1 AND s.brand_id = $2
ORDER BY s."time" ASC NULLS LAST, s.location ASC
LIMIT 5`,
[dateStr, brandId],
),
getAdminOrders(),
]);
const todayStops = stopsResult.rows;
const allOrders = ordersResult.success ? ordersResult.orders : [];
const pendingOrders = allOrders
.filter((o) => o.status === "pending" || o.status === "confirmed")
.slice(0, 5);
const hasAnyData =
summary.orders_today > 0 ||
summary.revenue_today > 0 ||
summary.pending_fulfillment > 0 ||
summary.stops_today > 0;
return (
<main>
<PageHeader title="Today" subtitle={formatDate(new Date())} />
<PullToRefresh>
<div className="px-4 pb-24 space-y-4">
{/* "Needs you" callout — surfaces the most actionable count
* at the top of the page. Tapping jumps to the filtered
* orders list. */}
{summary.pending_fulfillment > 0 && (
<Link
href="/admin/v2/orders?status=placed"
className="block rounded-2xl p-4 active:opacity-80 transition-opacity"
style={{
backgroundColor: "var(--color-warning-soft)",
borderLeft: "4px solid var(--color-warning)",
}}
>
<div
className="text-label font-semibold"
style={{
color: "var(--color-warning)",
letterSpacing: "0.02em",
}}
>
NEEDS YOU
</div>
<div
className="text-h2 mt-1"
style={{ fontWeight: 700, lineHeight: 1.3 }}
>
{summary.pending_fulfillment} order
{summary.pending_fulfillment === 1 ? "" : "s"} waiting
</div>
<div
className="text-meta mt-1"
style={{ color: "var(--color-text-muted)" }}
>
View all
</div>
</Link>
)}
{/* 2x2 stat grid. The "Pending" card picks up the warning
* accent when there's anything to act on. */}
<section
className="grid grid-cols-2 gap-3"
aria-label="Today's stats"
>
<StatCard
label="Orders"
value={summary.orders_today.toLocaleString("en-US")}
/>
<StatCard
label="Revenue"
value={`$${(summary.revenue_today / 100).toFixed(2)}`}
/>
<StatCard
label="Pending"
value={summary.pending_fulfillment.toLocaleString("en-US")}
accent={summary.pending_fulfillment > 0}
/>
<StatCard
label="Stops"
value={summary.stops_today.toLocaleString("en-US")}
/>
</section>
{/* Today's stops — at-a-glance list of the next 5 runs so
* the operator can see where they need to be today. */}
{todayStops.length > 0 && (
<section aria-label="Today's stops">
<h2
className="text-label font-semibold mb-3"
style={{
color: "var(--color-text-muted)",
letterSpacing: "0.02em",
}}
>
TODAY&apos;S STOPS
</h2>
<ul className="space-y-2">
{todayStops.map((stop) => {
const href = `/admin/v2/stops?date=${dateStr}`;
return (
<li key={stop.id}>
<Link
href={href}
className="block rounded-2xl p-4 active:scale-[0.99] transition-transform"
style={{
backgroundColor: "var(--color-surface-2)",
minHeight: "72px",
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
}}
>
<div
className="text-meta"
style={{ color: "var(--color-text-muted)" }}
>
{formatStopTime(stop.time)}
</div>
<div
className="text-h2 mt-1"
style={{
fontWeight: 700,
lineHeight: 1.3,
color: "var(--color-text)",
}}
>
{stopAddress(stop)}
</div>
</Link>
</li>
);
})}
</ul>
</section>
)}
{/* Top 5 pending orders — drilled into from the "Needs you"
* callout above. Each row tappable to the order detail. */}
{pendingOrders.length > 0 && (
<section aria-label="Pending orders">
<h2
className="text-label font-semibold mb-3"
style={{
color: "var(--color-text-muted)",
letterSpacing: "0.02em",
}}
>
PENDING ORDERS
</h2>
<ul className="space-y-2">
{pendingOrders.map((order) => {
const total = Number(order.subtotal ?? 0);
return (
<li key={order.id}>
<Link
href={`/admin/v2/orders/${order.id}`}
className="block rounded-2xl p-4 active:scale-[0.99] transition-transform"
style={{
backgroundColor: "var(--color-surface-2)",
minHeight: "72px",
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
}}
>
<div className="flex items-center justify-between gap-3 mb-1">
<StatusPill status={orderStatusKind(order.status)} />
<span
className="text-mono"
style={{ color: "var(--color-text-faint)" }}
>
#{order.id.slice(0, 8)}
</span>
<span
className="text-meta ml-auto"
style={{ color: "var(--color-text-muted)" }}
>
{formatRelativeTime(order.created_at)}
</span>
</div>
<div
className="text-h2"
style={{
fontWeight: 700,
lineHeight: 1.3,
color: "var(--color-text)",
}}
>
{order.customer_name ?? "Customer"}
</div>
<div
className="text-h1 mt-1"
style={{ fontWeight: 600, color: "var(--color-text)" }}
>
${total.toFixed(2)}
</div>
</Link>
</li>
);
})}
</ul>
</section>
)}
{/* Empty dashboard — no orders, no revenue, no stops. Keep
* the page useful instead of showing four zeros. */}
{!hasAnyData && (
<EmptyState
title="Nothing to show yet"
description="When orders, stops, or revenue roll in, they'll show up here."
icon={<span aria-hidden="true">🌱</span>}
/>
)}
</div>
</PullToRefresh>
</main>
);
}
/**
* Single stat tile. Mirrors the v2 product/stop cards' rounded-2xl +
* surface-2 background. `accent` swaps the fill + value color to the
* warning pair so the "Pending" card stands out when there's work to do.
*/
function StatCard({
label,
value,
accent = false,
}: {
label: string;
value: string;
accent?: boolean;
}) {
return (
<div
className="rounded-2xl p-4"
style={{
backgroundColor: accent
? "var(--color-warning-soft)"
: "var(--color-surface-2)",
minHeight: "88px",
}}
>
<div
className="text-label font-semibold"
style={{
color: "var(--color-text-muted)",
letterSpacing: "0.02em",
}}
>
{label.toUpperCase()}
</div>
<div
className="text-h1 font-display mt-1"
style={{
fontWeight: 600,
lineHeight: 1.2,
color: accent ? "var(--color-warning)" : "var(--color-text)",
}}
>
{value}
</div>
</div>
);
}