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:
parent
f7938375f6
commit
c9096dfd84
3 changed files with 44 additions and 88 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue