feat(dashboard2): trim Row 1 to Selling-only with last-month delta

- Drop Cost and Markup gauge — Selling is the headline the rep cares about
- monthRevenue is now {sellingThisMonth, sellingLastMonth}
- One combined PB query covers both months; classify each row by date in code so the delta costs zero extra round-trips
- RepKpiCard gains a format="currency" prop so Selling renders ZAR-formatted with a delta arrow (mirrors the quote-count cards)
- Row 1 grid adjusts from 6-col to 4-col to match the new card count
This commit is contained in:
Dave 2026-09-01 01:55:52 +02:00
parent f7938375f6
commit c9096dfd84
3 changed files with 44 additions and 88 deletions

View file

@ -9,8 +9,6 @@ import {
ShoppingCart,
CalendarDays,
TrendingUp,
Gauge,
CheckCircle,
AlertCircle,
DollarSign,
} from "lucide-react";
@ -19,7 +17,6 @@ import getPocketBase from "@/lib/pocketbase";
import { CHART, withAlpha } from "@/components/charts/palette";
import { DashboardCard } from "@/components/charts/dashboard-card";
import { BarChart } from "@/components/charts/bar-chart";
import { RadialGauge } from "@/components/charts/radial-gauge";
import type { RepDashboardData } from "@/lib/server/rep-dashboard-service";
function weekLabel(iso: string): string {
@ -114,8 +111,8 @@ export default function RepDashboardPage() {
</p>
</div>
{/* Row 1: quotes-loaded KPIs + this month's money (6 cards) */}
<div className="grid gap-3 grid-cols-1 sm:grid-cols-3 lg:grid-cols-6">
{/* Row 1: quotes-loaded KPIs + this month's selling (4 cards) */}
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
<RepKpiCard
label="Quotes today"
value={quotesLoaded.today}
@ -137,36 +134,14 @@ export default function RepDashboardPage() {
icon={TrendingUp}
accentColor={CHART.orange}
/>
<RepCurrencyKpiCard
label="Selling"
value={monthRevenue.selling}
<RepKpiCard
label="Selling this month"
value={monthRevenue.sellingThisMonth}
delta={deltaPct(monthRevenue.sellingThisMonth, monthRevenue.sellingLastMonth)}
icon={DollarSign}
accentColor={CHART.green}
format="currency"
/>
<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">
<CheckCircle className="h-7 w-7 mx-auto mb-1.5" style={{ color: CHART.green }} />
<p className="text-sm">No orders yet this month</p>
</div>
) : (
<RadialGauge
value={monthRevenue.cost}
total={monthRevenue.selling}
color={CHART.purple}
label="Markup"
sublabel={`${formatZar(monthRevenue.selling)} · ${formatZar(monthRevenue.cost)}`}
centerLabelOverride={`${monthRevenue.markupPct.toFixed(1)}%`}
size={140}
/>
)}
</DashboardCard>
</div>
{/* Row 2: trend + outstanding lists */}
@ -215,14 +190,18 @@ function RepKpiCard({
delta,
icon: Icon,
accentColor,
format = "number",
}: {
label: string;
value: number;
delta: number;
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
accentColor: string;
format?: "number" | "currency";
}) {
const positive = delta >= 0;
const display =
format === "currency" ? formatZar(value) : value.toLocaleString();
return (
<DashboardCard accentColor={accentColor} padding="p-4">
<div className="flex items-center justify-between gap-2 mb-2">
@ -235,7 +214,7 @@ function RepKpiCard({
</div>
</div>
<div className="text-2xl font-bold tracking-tight tabular-nums" style={{ fontFamily: "var(--font-jetbrains-mono), monospace" }}>
{value.toLocaleString()}
{display}
</div>
<div className="mt-0.5 text-[11px] font-semibold" style={{ color: positive ? CHART.green : CHART.red }}>
{positive ? "+" : ""}
@ -245,36 +224,6 @@ 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

@ -191,33 +191,32 @@ describe("getRepDashboardData", () => {
expect(data.outstandingOrders.items.map((i) => i.order_number).sort()).toEqual(["ORD1", "ORD2"]);
});
it("sums selling/cost and computes markup % for this month's order items", async () => {
it("sums selling for this month and last month in a single query", async () => {
seed("quotes", []);
seed("orders", []);
seed("order_items", [
{ id: "i1", "order.customer.rep": REP_ID, created: "2026-08-05T00:00:00.000Z", quantity: 2, selling_price: 100, cost_price: 50 },
{ id: "i2", "order.customer.rep": REP_ID, created: "2026-08-10T00:00:00.000Z", quantity: 1, selling_price: 40, cost_price: 20 },
{ id: "i3", "order.customer.rep": REP_ID, created: "2026-07-31T00:00:00.000Z", quantity: 1, selling_price: 999, cost_price: 1 }, // last month, excluded
{ id: "i4", "order.customer.rep": OTHER_REP_ID, created: "2026-08-05T00:00:00.000Z", quantity: 1, selling_price: 999, cost_price: 1 }, // different rep
{ id: "i1", "order.customer.rep": REP_ID, created: "2026-08-05T00:00:00.000Z", quantity: 2, selling_price: 100 },
{ id: "i2", "order.customer.rep": REP_ID, created: "2026-08-10T00:00:00.000Z", quantity: 1, selling_price: 40 },
{ id: "i3", "order.customer.rep": REP_ID, created: "2026-07-31T00:00:00.000Z", quantity: 1, selling_price: 999 }, // last month
{ id: "i4", "order.customer.rep": OTHER_REP_ID, created: "2026-08-05T00:00:00.000Z", quantity: 1, selling_price: 999 }, // different rep
{ id: "i5", "order.customer.rep": REP_ID, created: "2026-06-15T00:00:00.000Z", quantity: 1, selling_price: 500 }, // outside both months
]);
const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);
// selling = 2*100 + 1*40 = 240; cost = 2*50 + 1*20 = 120
expect(data.monthRevenue.selling).toBe(240);
expect(data.monthRevenue.cost).toBe(120);
expect(data.monthRevenue.markupPct).toBe(100); // (240-120)/120 * 100
// this month = 2*100 + 1*40 = 240; last month = 1*999 = 999
expect(data.monthRevenue.sellingThisMonth).toBe(240);
expect(data.monthRevenue.sellingLastMonth).toBe(999);
});
it("returns markupPct: null when there's no cost this month (no div-by-zero)", async () => {
it("returns zeroes when there are no order items this month or last", async () => {
seed("quotes", []);
seed("orders", []);
seed("order_items", []);
const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);
expect(data.monthRevenue.selling).toBe(0);
expect(data.monthRevenue.cost).toBe(0);
expect(data.monthRevenue.markupPct).toBeNull();
expect(data.monthRevenue.sellingThisMonth).toBe(0);
expect(data.monthRevenue.sellingLastMonth).toBe(0);
});
});

View file

@ -31,9 +31,8 @@ export interface RepDashboardData {
items: { id: string; order_number: string; customer_name: string | null }[];
};
monthRevenue: {
selling: number;
cost: number;
markupPct: number | null;
sellingThisMonth: number;
sellingLastMonth: number;
};
}
@ -172,8 +171,10 @@ export async function getRepDashboardData(
},
),
pb.collection(getOrderItemsCollectionName()).getList(1, MONTH_ORDER_ITEM_LIMIT, {
filter: `${orderRepFilter} && ${rangeFilter("created", thisMonthStart, nextMonthStart)}`,
fields: "id,quantity,order_qty,selling_price,cost_price",
// Single query covers both months; classify each row by `created`
// in code so we don't pay a second round-trip just for the delta.
filter: `${orderRepFilter} && created >= "${lastMonthStart.toISOString()}" && created < "${nextMonthStart.toISOString()}"`,
fields: "id,created,quantity,order_qty,selling_price",
}),
]);
@ -227,19 +228,26 @@ export async function getRepDashboardData(
};
/* ---------- monthRevenue ---------- */
let selling = 0;
let cost = 0;
let sellingThisMonth = 0;
let sellingLastMonth = 0;
const thisMonthStartMs = thisMonthStart.getTime();
for (const it of monthOrderItems.items as unknown as Array<{
created?: string;
quantity?: number | null;
order_qty?: number | null;
selling_price?: number | null;
cost_price?: number | null;
}>) {
const qty = Number(it.quantity ?? it.order_qty ?? 0);
selling += Number(it.selling_price ?? 0) * qty;
cost += Number(it.cost_price ?? 0) * qty;
const lineTotal = Number(it.selling_price ?? 0) * qty;
// `created` is an ISO string — compare on its parsed timestamp so we
// don't tie this to a particular date-string format PB happens to use.
const createdMs = it.created ? new Date(it.created).getTime() : 0;
if (createdMs >= thisMonthStartMs) {
sellingThisMonth += lineTotal;
} else {
sellingLastMonth += lineTotal;
}
}
const markupPct = cost > 0 ? ((selling - cost) / cost) * 100 : null;
return {
quotesLoaded: {
@ -253,6 +261,6 @@ export async function getRepDashboardData(
quoteVolumeTrend,
outstandingQuotes,
outstandingOrders,
monthRevenue: { selling, cost, markupPct },
monthRevenue: { sellingThisMonth, sellingLastMonth },
};
}