0ac4beaaa8
- Add requireAuth() to admin-permissions.ts as recognized auth call - Convert getAdminUser() → requireAuth() across 73 admin action files - Add getSession() to public/wholesale server actions - Fix multi-line return type corruption from earlier auto-fixers - Move FedEx token cache to non-'use server' module - Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD, EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS - Update Stripe API version 2026-05-27 → 2026-06-24 - Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient - Fix 51 TypeScript errors (return type corruption, missing imports)
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
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);
|
|
|
|
// Compute today's date string once per request on the server so the
|
|
// PageHeader subtitle is a stable, formatted value (avoids any
|
|
// hydration-mismatch risk from rendering `new Date()` inline in JSX).
|
|
const todayLabel = formatDate(new Date());
|
|
|
|
if (!activeBrandId) {
|
|
return (
|
|
<main>
|
|
<PageHeader title="Today" subtitle={todayLabel} />
|
|
<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={todayLabel} />
|
|
<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>
|
|
);
|
|
}
|