diff --git a/src/actions/dashboard.ts b/src/actions/dashboard.ts index 3293d2a..f41dc9e 100644 --- a/src/actions/dashboard.ts +++ b/src/actions/dashboard.ts @@ -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 { @@ -276,6 +300,48 @@ export async function getDashboardSummary(): Promise { } } +/** + * 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 { + 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 { diff --git a/src/app/admin/v2/page.tsx b/src/app/admin/v2/page.tsx new file mode 100644 index 0000000..bf6c26f --- /dev/null +++ b/src/app/admin/v2/page.tsx @@ -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 ( +
+ + + +
+ ); + } + + 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} +
+
+ ); +}