Files
route-commerce/src/actions/dashboard.ts
T

379 lines
12 KiB
TypeScript

"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<MobileDashboardSummary> = 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<DashboardStats> {
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<DashboardSummary> {
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<MobileDashboardSummary> {
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" });
}