379 lines
12 KiB
TypeScript
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" });
|
|
}
|