"use server"; import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; import { pool } from "@/lib/db"; import { getSession } from "@/lib/auth"; // ── Types ──────────────────────────────────────────────────────────────────── export type DashboardStats = { todayOrders: number; todayRevenue: number; pendingStops: number; activeProducts: number; weeklyOrders: number[]; recentOrders: Array<{ id: string; customer_name: string; total: number; status: string; created_at: string; }>; }; export type DashboardSummary = { total_revenue: number; total_orders: number; active_stops: number; 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: Readonly = Object.freeze({ orders_today: 0, revenue_today: 0, pending_fulfillment: 0, stops_today: 0, orders_last_7_days: [], }); // ── Dashboard Stats Actions ───────────────────────────────────────────────── export async function getDashboardStats(): Promise { await getSession(); try { const adminUser = await getAdminUser(); if (!adminUser) throw new Error("Not authenticated"); const brandId = await getActiveBrandId(adminUser); // Get today's date range const today = new Date(); const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); // Fetch today's orders. New schema: `total_cents` (int, divide by 100 // for dollars) and `placed_at` (not legacy `subtotal` / `created_at`). const todayOrdersRes = brandId ? await pool.query<{ total_cents: number; status: string }>( `SELECT total_cents, status FROM orders WHERE placed_at >= $1 AND placed_at < $2 AND brand_id = $3`, [startOfDay.toISOString(), endOfDay.toISOString(), brandId], ) : await pool.query<{ total_cents: number; status: string }>( `SELECT total_cents, status FROM orders WHERE placed_at >= $1 AND placed_at < $2`, [startOfDay.toISOString(), endOfDay.toISOString()], ); const todayOrders = todayOrdersRes.rows; // Calculate today's revenue and orders const validOrders = todayOrders.filter((o) => o.status !== "cancelled"); const todayRevenue = validOrders.reduce( (sum, o) => sum + ((o.total_cents || 0) / 100), 0, ); const todayOrderCount = validOrders.length; // Fetch pending stops (stops where date >= today and status is scheduled) const stopsRes = brandId ? await pool.query<{ id: string }>( `SELECT id FROM stops WHERE date >= $1 AND status = 'scheduled' AND brand_id = $2 LIMIT 100`, [startOfDay.toISOString().split("T")[0], brandId], ) : await pool.query<{ id: string }>( `SELECT id FROM stops WHERE date >= $1 AND status = 'scheduled' LIMIT 100`, [startOfDay.toISOString().split("T")[0]], ); const pendingStops = stopsRes.rows.length; // Fetch active products const productsRes = brandId ? await pool.query<{ id: string }>( `SELECT id FROM products WHERE active = true AND brand_id = $1 LIMIT 1000`, [brandId], ) : await pool.query<{ id: string }>( `SELECT id FROM products WHERE active = true LIMIT 1000`, ); const activeProducts = productsRes.rows.length; // Fetch weekly orders for chart (last 7 days). New schema uses `placed_at`. const weeklyOrders: number[] = []; for (let i = 6; i >= 0; i--) { const dayStart = new Date(startOfDay); dayStart.setDate(dayStart.getDate() - i); const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); const dayRes = brandId ? await pool.query<{ id: string }>( `SELECT id FROM orders WHERE placed_at >= $1 AND placed_at < $2 AND brand_id = $3 LIMIT 1`, [dayStart.toISOString(), dayEnd.toISOString(), brandId], ) : await pool.query<{ id: string }>( `SELECT id FROM orders WHERE placed_at >= $1 AND placed_at < $2 LIMIT 1`, [dayStart.toISOString(), dayEnd.toISOString()], ); weeklyOrders.push(dayRes.rows.length > 0 ? 1 : 0); } // Fetch recent orders (last 10). New schema: `total_cents`/`placed_at` // on `orders`; customer name comes from a `customers` join (no // `customer_name` column on `orders`). const recentRes = brandId ? await pool.query<{ id: string; customer_name: string; total_cents: number; status: string; placed_at: string; }>( `SELECT o.id::text AS id, TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name, o.total_cents, o.status, o.placed_at::text AS placed_at FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE o.brand_id = $1 ORDER BY o.placed_at DESC LIMIT 10`, [brandId], ) : await pool.query<{ id: string; customer_name: string; total_cents: number; status: string; placed_at: string; }>( `SELECT o.id::text AS id, TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name, o.total_cents, o.status, o.placed_at::text AS placed_at FROM orders o LEFT JOIN customers c ON c.id = o.customer_id ORDER BY o.placed_at DESC LIMIT 10`, ); const recentOrders: Array<{ id: string; customer_name: string; total: number; status: string; created_at: string; }> = []; for (const o of recentRes.rows) { if (o.status === "cancelled") continue; recentOrders.push({ id: o.id || "", customer_name: o.customer_name || "Guest", total: (o.total_cents || 0) / 100, status: o.status || "unknown", created_at: formatTimeAgo(o.placed_at), }); } return { todayOrders: todayOrderCount, todayRevenue: Math.round(todayRevenue * 100) / 100, pendingStops, activeProducts, weeklyOrders, recentOrders, }; } catch (error) { console.error("Failed to fetch dashboard stats:", error); return { todayOrders: 0, todayRevenue: 0, pendingStops: 0, activeProducts: 0, weeklyOrders: [0, 0, 0, 0, 0, 0, 0], recentOrders: [], }; } } async function getDashboardSummary(): Promise { await getSession(); try { const adminUser = await getAdminUser(); if (!adminUser) throw new Error("Not authenticated"); const brandId = await getActiveBrandId(adminUser); const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); // `get_reports_summary` is a SECURITY DEFINER RPC that returns the // gross sales + total order counts. Migration 031. let total_revenue = 0; let total_orders = 0; try { const { rows } = await pool.query<{ gross_sales: number; total_orders: number; }>( "SELECT * FROM get_reports_summary($1, $2, $3)", [ brandId, thirtyDaysAgo.toISOString().split("T")[0], new Date().toISOString().split("T")[0], ], ); const data = rows[0]; total_revenue = data?.gross_sales ?? 0; total_orders = data?.total_orders ?? 0; } catch { // Fall through with zeros if the RPC is missing. } // Get active stops count const stopsRes = brandId ? await pool.query<{ id: string }>( `SELECT id FROM stops WHERE date >= $1 AND status = 'scheduled' AND brand_id = $2`, [new Date().toISOString().split("T")[0], brandId], ) : await pool.query<{ id: string }>( `SELECT id FROM stops WHERE date >= $1 AND status = 'scheduled'`, [new Date().toISOString().split("T")[0]], ); const activeStops = stopsRes.rows.length; // Get active products count const productsRes = brandId ? await pool.query<{ id: string }>( `SELECT id FROM products WHERE active = true AND brand_id = $1`, [brandId], ) : await pool.query<{ id: string }>( `SELECT id FROM products WHERE active = true`, ); const activeProducts = productsRes.rows.length; return { total_revenue, total_orders, active_stops: activeStops, active_products: activeProducts, }; } catch (error) { console.error("Failed to fetch dashboard summary:", error); return { total_revenue: 0, total_orders: 0, active_stops: 0, active_products: 0, }; } } /** * 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 { await getSession(); 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 { if (!dateString) return "Unknown"; const date = new Date(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return "Just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); }