- 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
437 lines
No EOL
14 KiB
TypeScript
437 lines
No EOL
14 KiB
TypeScript
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,
|
||
}; |