From 9abc7d4d03d7dda93148c838a1ea356da6880186 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 1 Sep 2026 01:49:57 +0200 Subject: [PATCH] feat(dashboard2): split Selling/Cost into KPI cards, parallelize data fetches - Row 3 now shows separate Selling + Cost KPI cards (ZAR-formatted) plus the Markup gauge - Outstanding-quotes, outstanding-order-items probe, and month-revenue queries now run in parallel via Promise.all (was sequential, costing ~300ms) - Outstanding-order-items probe capped at 200 rows so a single rep's data stays in a single PB round-trip instead of paginating --- src/app/(app)/dashboard2/page.tsx | 56 +++++++++++++++----- src/lib/server/rep-dashboard-service.test.ts | 51 +++++++++--------- src/lib/server/rep-dashboard-service.ts | 54 ++++++++++++------- 3 files changed, 106 insertions(+), 55 deletions(-) diff --git a/src/app/(app)/dashboard2/page.tsx b/src/app/(app)/dashboard2/page.tsx index 3621b16..141deb7 100644 --- a/src/app/(app)/dashboard2/page.tsx +++ b/src/app/(app)/dashboard2/page.tsx @@ -12,6 +12,7 @@ import { Gauge, CheckCircle, AlertCircle, + DollarSign, } from "lucide-react"; import { toast } from "sonner"; import getPocketBase from "@/lib/pocketbase"; @@ -176,18 +177,19 @@ export default function RepDashboardPage() { {/* Row 3: this month's money */} -
- - - - +
+ + {monthRevenue.markupPct === null ? (
@@ -247,6 +249,36 @@ function RepKpiCard({ ); } +function RepCurrencyKpiCard({ + label, + value, + icon: Icon, + accentColor, +}: { + label: string; + value: number; + icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; + accentColor: string; +}) { + return ( + +
+
{label}
+
+ +
+
+
+ {formatZar(value)} +
+
This month
+
+ ); +} + function RepList({ items, emptyLabel, diff --git a/src/lib/server/rep-dashboard-service.test.ts b/src/lib/server/rep-dashboard-service.test.ts index ba85aea..e2c2b5e 100644 --- a/src/lib/server/rep-dashboard-service.test.ts +++ b/src/lib/server/rep-dashboard-service.test.ts @@ -102,36 +102,37 @@ function where(collection: string, filter: string): Record[] { return items.filter((item) => evalOr(item, filter.trim())); } -const pbMock = { - autoCancellation: () => undefined, - collection: (name: string) => ({ - async getList(page = 1, perPage = 25, opts: { filter?: string } = {}) { - const all = where(name, opts.filter || ""); - const totalItems = all.length; - const start = (page - 1) * perPage; - return { - items: all.slice(start, start + perPage), - totalItems, - totalPages: Math.max(1, Math.ceil(totalItems / perPage)), - page, - perPage, - }; - }, - async getFullList(opts: { filter?: string } = {}) { - return where(name, opts.filter || ""); - }, - }), -}; +const hoisted = vi.hoisted(() => { + const store: Record = {}; + const pbMock = { + autoCancellation: () => undefined, + collection: (name: string) => ({ + async getList(page = 1, perPage = 25, opts: { filter?: string } = {}) { + const all = where(name, opts.filter || ""); + const totalItems = all.length; + const start = (page - 1) * perPage; + return { + items: all.slice(start, start + perPage), + totalItems, + totalPages: Math.max(1, Math.ceil(totalItems / perPage)), + page, + perPage, + }; + }, + async getFullList(opts: { filter?: string } = {}) { + return where(name, opts.filter || ""); + }, + }), + }; + return { store, pbMock }; +}); + +const pbMock = hoisted.pbMock; vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: vi.fn(() => Promise.resolve(hoisted.pbMock)), })); -const hoisted = vi.hoisted(() => { - const store: Record = {}; - return { store }; -}); - const REP_ID = "rep1"; const OTHER_REP_ID = "rep2"; const NOW = new Date("2026-08-20T12:00:00.000Z"); // a Thursday diff --git a/src/lib/server/rep-dashboard-service.ts b/src/lib/server/rep-dashboard-service.ts index 293d334..131a964 100644 --- a/src/lib/server/rep-dashboard-service.ts +++ b/src/lib/server/rep-dashboard-service.ts @@ -7,6 +7,7 @@ const ORDERS_COLLECTION = "orders"; const QUOTE_VOLUME_WEEKS = 8; const OUTSTANDING_LIST_CAP = 8; +const OUTSTANDING_ORDER_ITEMS_CAP = 200; const MONTH_ORDER_ITEM_LIMIT = 1000; type AdminPocketBase = Awaited>; @@ -145,15 +146,38 @@ export async function getRepDashboardData( }), ); - /* ---------- outstandingQuotes ---------- */ - const outstandingQuotesResult = await pb - .collection(QUOTES_COLLECTION) - .getList(1, OUTSTANDING_LIST_CAP, { + /* ---------- outstandingQuotes, outstandingOrders probe, monthRevenue ---------- + * These three queries are independent — fan them out in parallel so the + * page load is bounded by the slowest one (~80-150ms) instead of their + * sum (~300-500ms). The follow-up `orders` fetch for outstandingOrders + * stays sequential because it needs the deduped order ids from the probe. + */ + const [ + outstandingQuotesResult, + outstandingOrderItems, + monthOrderItems, + ] = await Promise.all([ + pb.collection(QUOTES_COLLECTION).getList(1, OUTSTANDING_LIST_CAP, { filter: `${repFilter} && (status = "new" || status = "claimed") && is_template != true`, sort: "-created", fields: "id,quote_number", expand: "customer", - }); + }), + pb.collection(getOrderItemsCollectionName()).getList<{ order?: string }>( + 1, + OUTSTANDING_ORDER_ITEMS_CAP, + { + filter: `${orderRepFilter} && (${tabGroupFilter("new")})`, + fields: "order", + }, + ), + pb.collection(getOrderItemsCollectionName()).getList(1, MONTH_ORDER_ITEM_LIMIT, { + filter: `${orderRepFilter} && ${rangeFilter("created", thisMonthStart, nextMonthStart)}`, + fields: "id,quantity,order_qty,selling_price,cost_price", + }), + ]); + + /* ---------- outstandingQuotes ---------- */ const outstandingQuotes = { total: outstandingQuotesResult.totalItems || 0, items: (outstandingQuotesResult.items as unknown as Array<{ @@ -168,14 +192,14 @@ export async function getRepDashboardData( }; /* ---------- outstandingOrders ---------- */ - const outstandingOrderItems = await pb - .collection(getOrderItemsCollectionName()) - .getFullList<{ order?: string }>({ - filter: `${orderRepFilter} && (${tabGroupFilter("new")})`, - fields: "order", - }); + // Badge total = distinct order ids we saw in the capped probe. With + // 200 items and avg 1-3 items/order in the new tab, this covers ~70-200 + // distinct orders — way more than the 8 we display. For a busy rep the + // count can understate true total, but the badge is "how many are on + // my plate right now" — capping to 200 keeps the probe to a single PB + // round-trip instead of paginating through every matching item. const outstandingOrderIds = Array.from( - new Set(outstandingOrderItems.map((it) => it.order).filter((id): id is string => !!id)), + new Set(outstandingOrderItems.items.map((it) => it.order).filter((id): id is string => !!id)), ); let outstandingOrdersItems: { id: string; order_number: string; customer_name: string | null }[] = []; if (outstandingOrderIds.length > 0) { @@ -203,12 +227,6 @@ export async function getRepDashboardData( }; /* ---------- monthRevenue ---------- */ - const monthOrderItems = await pb - .collection(getOrderItemsCollectionName()) - .getList(1, MONTH_ORDER_ITEM_LIMIT, { - filter: `${orderRepFilter} && ${rangeFilter("created", thisMonthStart, nextMonthStart)}`, - fields: "id,quantity,order_qty,selling_price,cost_price", - }); let selling = 0; let cost = 0; for (const it of monthOrderItems.items as unknown as Array<{