@@ -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 (
+
+
+
+ {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<{