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
This commit is contained in:
Dave 2026-09-01 01:49:57 +02:00
parent 23050e3dc6
commit 9abc7d4d03
3 changed files with 106 additions and 55 deletions

View file

@ -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() {
</div>
{/* Row 3: this month's money */}
<div className="grid gap-3 lg:grid-cols-2">
<DashboardCard title="Selling vs Cost" subtitle="This month" icon={ShoppingCart} accentColor={CHART.green}>
<BarChart
data={[{ label: "This month", values: [monthRevenue.selling, monthRevenue.cost] }]}
series={[
{ label: "Selling", color: CHART.green },
{ label: "Cost", color: CHART.muted },
]}
height={180}
/>
</DashboardCard>
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<RepCurrencyKpiCard
label="Selling"
value={monthRevenue.selling}
icon={DollarSign}
accentColor={CHART.green}
/>
<RepCurrencyKpiCard
label="Cost"
value={monthRevenue.cost}
icon={DollarSign}
accentColor={CHART.muted}
/>
<DashboardCard title="Markup %" subtitle="This month" icon={Gauge} accentColor={CHART.purple}>
{monthRevenue.markupPct === null ? (
<div className="py-8 text-center text-muted-foreground">
@ -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 (
<DashboardCard accentColor={accentColor} padding="p-4">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{label}</div>
<div
className="grid h-7 w-7 place-items-center rounded-md shrink-0"
style={{ background: withAlpha(accentColor, 14), color: accentColor }}
>
<Icon className="h-3.5 w-3.5" style={{ color: accentColor }} />
</div>
</div>
<div className="text-2xl font-bold tracking-tight tabular-nums" style={{ fontFamily: "var(--font-jetbrains-mono), monospace" }}>
{formatZar(value)}
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">This month</div>
</DashboardCard>
);
}
function RepList({
items,
emptyLabel,

View file

@ -102,36 +102,37 @@ function where(collection: string, filter: string): Record<string, unknown>[] {
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<string, StoreMap> = {};
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<string, StoreMap> = {};
return { store };
});
const REP_ID = "rep1";
const OTHER_REP_ID = "rep2";
const NOW = new Date("2026-08-20T12:00:00.000Z"); // a Thursday

View file

@ -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<ReturnType<typeof getAdminPocketBase>>;
@ -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<{