WFDASH-WEBAPP/src/lib/server/dashboard-service.ts
SJCoetz 12fa65a481 feat: add new order, invoice, and dashboard management tools
- Add server-side `/api/v1/dashboard` endpoint to reduce client-side payload and round trips
- Implement invoice upload and order-item SKU update APIs with complete test suites
- Refactor order and order-items list endpoints (including MCP tools) to support proper PocketBase filter and sort params
- Fix pagination validation for order-items route to clamp page ≥1 and perPage 1-500
- Add redirects in next.config.ts to map underscore collection paths to hyphenated API routes
- Improve buyer tasks page to avoid redundant tab count refetches when switching tabs
- Refactor collection confirm components with editable SKUs and updated accessibility labels
- Add cached admin PocketBase auth to minimize repeated authentication calls
- Add performance testing scripts for dashboard endpoint latency
- Update quotes list route to default to excluding archived counts
- Fix photo strip component text and labels for non-invoice documents
- Refactor sprint page to lazy load supplier/customer data for pricing modal
- Clean up unused imports in test files
- Add full test coverage for all new APIs, services, and components
2026-08-05 23:00:54 +02:00

437 lines
No EOL
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};