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:
parent
23050e3dc6
commit
9abc7d4d03
3 changed files with 106 additions and 55 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
Loading…
Reference in a new issue