feat(admin): add v2 dashboard with stat cards and today's stops
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user