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 (
); } 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( `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 (
{/* "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 && (
NEEDS YOU
{summary.pending_fulfillment} order {summary.pending_fulfillment === 1 ? "" : "s"} waiting
View all →
)} {/* 2x2 stat grid. The "Pending" card picks up the warning * accent when there's anything to act on. */}
0} />
{/* 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 && (

TODAY'S STOPS

    {todayStops.map((stop) => { const href = `/admin/v2/stops?date=${dateStr}`; return (
  • {formatStopTime(stop.time)}
    {stopAddress(stop)}
  • ); })}
)} {/* Top 5 pending orders — drilled into from the "Needs you" * callout above. Each row tappable to the order detail. */} {pendingOrders.length > 0 && (

PENDING ORDERS

    {pendingOrders.map((order) => { const total = Number(order.subtotal ?? 0); return (
  • #{order.id.slice(0, 8)} {formatRelativeTime(order.created_at)}
    {order.customer_name ?? "Customer"}
    ${total.toFixed(2)}
  • ); })}
)} {/* Empty dashboard — no orders, no revenue, no stops. Keep * the page useful instead of showing four zeros. */} {!hasAnyData && (
); } /** * 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 (
{label.toUpperCase()}
{value}
); }