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
+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>
);
}