WFDASH-WEBAPP/src/lib/server/dashboard-service.ts

437 lines
14 KiB
TypeScript
Raw Normal View History

import { getAdminPocketBase } from "./pocketbase-admin";
/**
* Server-side dashboard read model for `/dashboard`.
*
* Why this exists:
*
* The page used to fire 11 parallel PocketBase calls straight from the browser,
* each pulling the full record shape (`email_body`, `migration_meta`,
* `qb_invoice_pdf`, etc.) even though the dashboard only consumes a few
* fields per row. On the live PB (`pocketbase-gnqc88suy8fqk6wyryjjdpvl…
* sslip.io`) that cost ~720ms cold wall-clock and ~500KB of useless payload
* just to draw charts.
*
* This service collapses the dashboard into a single server round-trip that
* returns a small, fixed-shape payload (~10KB) and applies all the
* bucketing / aggregation work on the server side using `Promise.all`.
*
* The page can then render from one request, and realtime re-fires are
* debounced on the client so a burst of changes doesn't re-pull 11 queries
* back-to-back.
*
* Hard rules:
*
* - Every list query uses `fields=` projection to drop the heavy blobs.
* If you add a field to the page, add it here too — never widen a query
* to "all fields by default".
* - Status buckets + top customers are computed from the SAME 500-row
* sample the historical charts use, with a `sampled` flag so the page
* can show "n of N" when the buckets are incomplete. The previous
* dashboard showed 6,076 active quotes on the KPI while the donut
* showed 97 approved — a clear mismatch we're fixing in one place.
* - Counts use `getList(1, 1)` with a filter and the `id` field
* projected, never `getFullList`. `getFullList` defaults to 500
* records and transfers them all just to compute the count.
* - Follow-ups use a tight `perPage=50` ceiling (we only display 8 cards).
* - Order-item weeks use `fields="created,quantity,order_qty,selling_price,item_status"`
* so each row is ~70 bytes instead of ~1KB.
*/
const CUSTOMERS_COLLECTION = "customers";
const QUOTES_COLLECTION = "quotes";
const ORDERS_COLLECTION = "orders";
const ORDER_ITEMS_COLLECTION = "order_items";
const STOCK_ITEMS_COLLECTION = "stock_items";
const SAMPLE_WINDOW_DAYS = 84; // 12 weeks
const WEEK_BUCKETS = 12;
const ORDERS_BUCKETS = 8;
const QUOTE_SAMPLE_LIMIT = 500;
const ORDER_SAMPLE_LIMIT = 500;
const CUSTOMER_SAMPLE_LIMIT = 500;
const ORDER_ITEM_SAMPLE_LIMIT = 500;
const ORDER_ITEM_MONTH_LIMIT = 500;
const FOLLOW_UP_LIMIT = 50;
const TOP_CUSTOMERS = 6;
export interface DashboardStats {
totalCustomers: number;
activeQuotes: number;
ordersThisMonth: number;
totalRevenue: number;
/** True if the 500-row sample was a subset of a larger matching set. */
sampled: boolean;
}
export interface WeekPoint {
weekStart: string; // ISO date (YYYY-MM-DD)
count: number;
}
export interface RevenuePoint {
weekStart: string;
count: number;
}
export interface QuoteStatusBucket {
status: string;
label: string;
count: number;
}
export interface TopCustomer {
name: string;
count: number;
color: string;
}
export interface StockSummary {
totalItems: number;
totalQty: number;
lowStockCount: number;
outOfStockCount: number;
}
export interface FollowUpQuote {
id: string;
quote_number: string;
follow_up_due: string;
customer_name: string | null;
}
export interface DashboardData {
stats: DashboardStats;
/** 12-week customer creation count, oldest → newest. */
customersByWeek: WeekPoint[];
/** 8-week order creation count, oldest → newest. */
ordersByWeek: WeekPoint[];
/** 8-week fulfilled item count (delivered/invoiced/completed/checked_in). */
fulfilledByWeek: WeekPoint[];
/** 12-week quote creation count, oldest → newest. */
quotesByWeek: WeekPoint[];
/** 12-week revenue (selling_price × qty), oldest → newest. */
revenueByWeek: RevenuePoint[];
/** Sorted by count desc, capped at 6. */
quoteStatusBuckets: QuoteStatusBucket[];
/** Top customers by quote count (within the 12-week sample). */
topCustomers: TopCustomer[];
stockSummary: StockSummary;
followUps: FollowUpQuote[];
}
/* ============================================================================
* Status metadata (kept in sync with the page render)
* ========================================================================== */
const QUOTE_STATUS_META: Record<string, { label: string }> = {
new: { label: "New" },
claimed: { label: "Claimed" },
processed: { label: "Processed" },
emailed: { label: "Emailed" },
approval_pending: { label: "Approval pending" },
pending_approvals: { label: "Approval pending" },
approved: { label: "Approved" },
complete: { label: "Complete" },
rejected: { label: "Rejected" },
cancelled: { label: "Cancelled" },
problem: { label: "Problem" },
email_failed: { label: "Email failed" },
follow_up: { label: "Follow-up" },
template: { label: "Template" },
};
const TOP_PALETTE = [
"#06B6D4", // cyan
"#EC4899", // pink
"#F59E0B", // gold
"#F97316", // orange
"#A855F7", // purple
"#10B981", // green
"#3B82F6", // blue
"#EF4444", // red
] as const;
const FULFILLED_STATUSES = new Set([
"delivered",
"invoiced",
"completed",
"checked_in",
]);
/* ============================================================================
* Week helpers
* ========================================================================== */
function startOfWeekUtc(d: Date): Date {
const out = new Date(d);
const day = out.getUTCDay();
const diff = day === 0 ? -6 : 1 - day; // Monday-anchored week
out.setUTCDate(out.getUTCDate() + diff);
out.setUTCHours(0, 0, 0, 0);
return out;
}
function isoWeekKey(d: Date): string {
return startOfWeekUtc(d).toISOString().slice(0, 10);
}
function buildWeekBuckets(n: number, now: Date): Record<string, number> {
const nowWeek = startOfWeekUtc(now);
const out: Record<string, number> = {};
for (let i = n - 1; i >= 0; i--) {
const w = new Date(nowWeek);
w.setUTCDate(w.getUTCDate() - i * 7);
out[isoWeekKey(w)] = 0;
}
return out;
}
function bucketsToWeekPoints(b: Record<string, number>): WeekPoint[] {
return Object.entries(b)
.sort(([a], [z]) => a.localeCompare(z))
.map(([weekStart, count]) => ({ weekStart, count }));
}
/**
* Accept the optional `pb` from the caller so the test suite can inject a
* mock without touching the admin auth cache. Production callers omit it.
*/
export async function getDashboardData(
now: Date = new Date(),
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
): Promise<DashboardData> {
const pb = pbArg ?? (await getAdminPocketBase());
pb.autoCancellation(false);
const startOfMonth = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),
).toISOString();
const sampleSince = new Date(
now.getTime() - SAMPLE_WINDOW_DAYS * 24 * 60 * 60 * 1000,
).toISOString();
const [
customersTotal,
activeQuotesCount,
ordersMonthCount,
quotesAllSample,
ordersSample,
customersSample,
orderItemsMonth,
orderItemsSample,
quotesSample,
stockItems,
followUps,
] = await Promise.all([
// KPI counts — `fields="id"` so the response is one id + totalItems,
// never the full record.
pb.collection(CUSTOMERS_COLLECTION).getList(1, 1, { fields: "id" }),
pb.collection(QUOTES_COLLECTION).getList(1, 1, {
filter:
'(status = "new" || status = "claimed" || status = "processed" || status = "emailed" || status = "approval_pending" || status = "pending_approvals" || status = "follow_up" || status = "problem") && is_template != true',
fields: "id",
}),
pb.collection(ORDERS_COLLECTION).getList(1, 1, {
filter: `created >= "${startOfMonth}"`,
fields: "id",
}),
// Sampled status buckets + top-customers source.
// `customer.expand` only needs the customer's display name — projects
// the customer's `customer` field so the full customer record isn't
// returned.
pb.collection(QUOTES_COLLECTION).getList(1, QUOTE_SAMPLE_LIMIT, {
filter: "is_template != true",
fields: "id,status,created",
expand: "customer.customer",
}),
pb.collection(ORDERS_COLLECTION).getList(1, ORDER_SAMPLE_LIMIT, {
sort: "-created",
filter: `created >= "${sampleSince}"`,
fields: "id,created",
}),
pb.collection(CUSTOMERS_COLLECTION).getList(1, CUSTOMER_SAMPLE_LIMIT, {
sort: "-created",
filter: `created >= "${sampleSince}"`,
fields: "id,created",
}),
pb.collection(ORDER_ITEMS_COLLECTION).getList(1, ORDER_ITEM_MONTH_LIMIT, {
filter: `created >= "${startOfMonth}"`,
fields: "id,quantity,order_qty,selling_price",
}),
pb.collection(ORDER_ITEMS_COLLECTION).getList(1, ORDER_ITEM_SAMPLE_LIMIT, {
filter: `created >= "${sampleSince}"`,
fields: "id,created,item_status,quantity,order_qty,selling_price",
}),
pb.collection(QUOTES_COLLECTION).getList(1, QUOTE_SAMPLE_LIMIT, {
filter: `created >= "${sampleSince}" && is_template != true`,
fields: "id,created",
}),
// Stock items: 115 rows on live, but use fields projection so we don't
// ever ship the `cost_history` blob (which can grow over time).
pb.collection(STOCK_ITEMS_COLLECTION).getList(1, 200, {
filter: "is_active = true",
fields:
"id,sku,name,description,location,current_qty,min_qty,target_qty,is_active",
}),
pb.collection(QUOTES_COLLECTION).getList(1, FOLLOW_UP_LIMIT, {
sort: "follow_up_due",
filter: `follow_up_due != "" && follow_up_due <= "${now.toISOString()}" && status != "complete" && status != "cancelled" && status != "rejected" && is_template != true`,
fields: "id,quote_number,follow_up_due",
expand: "customer.customer",
}),
]);
/* ---------- Month revenue + sampled flag ---------- */
let monthRevenue = 0;
for (const it of orderItemsMonth.items as Array<{
selling_price?: number | null;
quantity?: number | null;
order_qty?: number | null;
}>) {
monthRevenue +=
Number(it.selling_price ?? 0) * Number(it.quantity ?? it.order_qty ?? 0);
}
const totalQuotesInSample = (quotesAllSample.items as Array<unknown>).length;
const sampleLimitReached =
totalQuotesInSample >= QUOTE_SAMPLE_LIMIT &&
(quotesAllSample.totalItems ?? 0) > QUOTE_SAMPLE_LIMIT;
/* ---------- Week buckets ---------- */
const customerWeeks = buildWeekBuckets(WEEK_BUCKETS, now);
for (const c of customersSample.items as unknown as Array<{ created: string }>) {
const k = isoWeekKey(new Date(c.created));
if (k in customerWeeks) customerWeeks[k] += 1;
}
const orderWeeks = buildWeekBuckets(ORDERS_BUCKETS, now);
for (const o of ordersSample.items as unknown as Array<{ created: string }>) {
const k = isoWeekKey(new Date(o.created));
if (k in orderWeeks) orderWeeks[k] += 1;
}
const fulfilledWeeks = buildWeekBuckets(ORDERS_BUCKETS, now);
const revenueWeeks = buildWeekBuckets(WEEK_BUCKETS, now);
for (const it of orderItemsSample.items as unknown as Array<{
created: string;
item_status?: string;
selling_price?: number | null;
quantity?: number | null;
order_qty?: number | null;
}>) {
const k = isoWeekKey(new Date(it.created));
if (FULFILLED_STATUSES.has(it.item_status ?? "") && k in fulfilledWeeks) {
fulfilledWeeks[k] += 1;
}
if (k in revenueWeeks) {
revenueWeeks[k] +=
Number(it.selling_price ?? 0) *
Number(it.quantity ?? it.order_qty ?? 0);
}
}
const quoteWeeks = buildWeekBuckets(WEEK_BUCKETS, now);
for (const q of quotesSample.items as unknown as Array<{ created: string }>) {
const k = isoWeekKey(new Date(q.created));
if (k in quoteWeeks) quoteWeeks[k] += 1;
}
/* ---------- Status buckets + top customers ---------- */
const statusCounts: Record<string, number> = {};
const customerQuoteCount: Record<string, number> = {};
for (const q of quotesAllSample.items as unknown as Array<{
status: string;
expand?: { customer?: { customer?: string } };
}>) {
statusCounts[q.status] = (statusCounts[q.status] ?? 0) + 1;
const name = q.expand?.customer?.customer;
if (name) {
customerQuoteCount[name] = (customerQuoteCount[name] ?? 0) + 1;
}
}
const quoteStatusBuckets: QuoteStatusBucket[] = Object.entries(statusCounts)
.map(([status, count]) => ({
status,
label: QUOTE_STATUS_META[status]?.label ?? status,
count,
}))
.sort((a, b) => b.count - a.count);
const topCustomers: TopCustomer[] = Object.entries(customerQuoteCount)
.map(([name, count], i) => ({
name,
count,
color: TOP_PALETTE[i % TOP_PALETTE.length],
}))
.sort((a, b) => b.count - a.count)
.slice(0, TOP_CUSTOMERS);
/* ---------- Stock summary ---------- */
let totalQty = 0;
let outOfStockCount = 0;
let lowStockCount = 0;
for (const s of stockItems.items as unknown as Array<{
current_qty?: number | null;
min_qty?: number | null;
}>) {
totalQty += Number(s.current_qty ?? 0);
const cur = Number(s.current_qty ?? 0);
const min = Number(s.min_qty ?? 0);
if (cur <= 0) outOfStockCount += 1;
else if (cur <= min) lowStockCount += 1;
}
/* ---------- Follow-ups ---------- */
const followUpsList: FollowUpQuote[] = (followUps.items as unknown as Array<{
id: string;
quote_number: string;
follow_up_due: string;
expand?: { customer?: { customer?: string } };
}>).map((q) => ({
id: q.id,
quote_number: q.quote_number,
follow_up_due: q.follow_up_due,
customer_name: q.expand?.customer?.customer ?? null,
}));
return {
stats: {
totalCustomers: customersTotal.totalItems,
activeQuotes: activeQuotesCount.totalItems,
ordersThisMonth: ordersMonthCount.totalItems,
totalRevenue: Math.round(monthRevenue * 100) / 100,
sampled: sampleLimitReached,
},
customersByWeek: bucketsToWeekPoints(customerWeeks),
ordersByWeek: bucketsToWeekPoints(orderWeeks),
fulfilledByWeek: bucketsToWeekPoints(fulfilledWeeks),
quotesByWeek: bucketsToWeekPoints(quoteWeeks),
revenueByWeek: bucketsToWeekPoints(revenueWeeks).map((p) => ({
weekStart: p.weekStart,
count: Math.round(p.count * 100) / 100,
})),
quoteStatusBuckets,
topCustomers,
stockSummary: {
totalItems: stockItems.totalItems,
totalQty,
lowStockCount,
outOfStockCount,
},
followUps: followUpsList,
};
}
/**
* Internal helper for tests + the daily cron that warms the cache. Not
* exposed via the API yet — included so future cache-aware callers don't
* reinvent it.
*/
export const _internals = {
startOfWeekUtc,
isoWeekKey,
buildWeekBuckets,
bucketsToWeekPoints,
QUOTE_STATUS_META,
FULFILLED_STATUSES,
};