11 KiB
Rep Dashboard ("Dashboard 2") — Design
Purpose
The existing /dashboard is a company-wide overview (11 parallel PB
queries, sampled/bucketed aggregates) built for a boss/admin audience.
Reps don't need most of it — top customers company-wide, stock alerts,
global revenue trends aren't their job. This spec defines a new,
separate, lighter dashboard scoped to "my stuff": how many quotes I
loaded and when, which of my quotes/orders are still outstanding, and
what my orders are worth this month (selling, cost, markup).
It is deliberately a new page, not a modification of the existing
/dashboard. Retrofitting a rep filter into dashboard-service.ts's
11 tuned, sampled queries was assessed as higher-risk than building a
small dedicated service from scratch — this page needs simple exact
counts for one rep, not company-wide sampling. /dashboard (dashboard
- is untouched by this work. Once dashboard 2 is built and reviewed, a follow-up decision (not part of this spec) is whether it replaces dashboard 1, becomes rep-only while bosses keep dashboard 1, or both stay as separate routes.
Scope decisions (confirmed during brainstorming)
- Route: new page at
/dashboard2(working name — rename/promote later). - Outstanding quotes:
status = "new" || status = "claimed", scoped tocustomer.rep = <me>, excluding templates. - Outstanding orders: orders with at least one line item in the
newtab group (seeTAB_GROUP_ITEM_STATUSES.newinsrc/lib/item-status-ui.ts), scoped tocustomer.rep = <me>. - "Quotes loaded": counted by the quote's
createddate (not claimed-by date), scoped tocustomer.rep = <me>, excluding templates. - Comparison windows: calendar-aligned — today vs yesterday, this
ISO week vs last ISO week, this calendar month vs last calendar
month. Matches the week-bucketing convention already used in
dashboard-service.ts(isoWeekKey). - Revenue scope: all
order_itemslines whose parent order'scustomer.rep = <me>, filtered by the order_item's owncreateddate falling in the current calendar month (matchesdashboard-service.ts's existingmonthRevenuecalc, just rep-scoped).selling_price × quantitysummed for "selling",cost_price × quantitysummed for "cost", markup % =(selling - cost) / cost * 100. - Auth: any logged-in user can open
/dashboard2— it always scopes to "whoever is logged in" via their PB user id, nouser_type === "REP"gate. A non-rep (e.g. an admin with no customers assigned to them) just sees zeros everywhere. This keeps the page simple and testable by anyone during review, without needing a redirect/role-check layer yet.
Architecture
New files only — nothing existing is modified except
browser-session.ts (additive: a new export, existing export
untouched).
src/lib/server/rep-dashboard-service.ts — data aggregation
src/app/api/v1/rep-dashboard/route.ts — thin route handler
src/app/(app)/dashboard2/page.tsx — page (client component)
src/lib/server/browser-session.ts — MODIFY: add getBrowserSessionUser()
getBrowserSessionUser (browser-session.ts)
assertBrowserSession today validates the bearer token against PB's
auth-refresh endpoint and discards the response body. This spec
needs the resolved user's id (to scope every query) — so we add a
sibling function that does the same validation but returns the parsed
user record instead of void:
export interface BrowserSessionUser {
id: string;
user_type: string[] | null;
}
export async function getBrowserSessionUser(
req: NextRequest,
): Promise<BrowserSessionUser> {
// same token extraction + auth-refresh call as assertBrowserSession,
// but returns { id, user_type } parsed from the response body's
// `record` field instead of discarding it.
}
assertBrowserSession stays as-is (other routes use it and don't need
the user). rep-dashboard/route.ts uses the new function instead.
getRepDashboardData (rep-dashboard-service.ts)
export interface RepDashboardData {
quotesLoaded: {
today: number; yesterday: number;
thisWeek: number; lastWeek: number;
thisMonth: number; lastMonth: number;
};
quoteVolumeTrend: { weekStart: string; count: number }[]; // 8 weeks
outstandingQuotes: {
total: number;
items: { id: string; quote_number: string; customer_name: string | null }[]; // capped at 8
};
outstandingOrders: {
total: number;
items: { id: string; order_number: string; customer_name: string | null }[]; // capped at 8
};
monthRevenue: {
selling: number;
cost: number;
markupPct: number | null; // null when cost is 0 (avoid div-by-zero)
};
}
export async function getRepDashboardData(
repId: string,
now: Date = new Date(),
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
): Promise<RepDashboardData>
All PB reads run in Promise.all, each a narrow getList/getFullList
with a fields projection (mirrors the existing dashboard-service
convention) and a filter built around customer.rep = "<repId>"
(quotes, and orders via the direct customer relation) or
order.customer.rep = "<repId>" (order_items, a 2-hop relation
chain — same technique already used in the invoicing rep-filter work).
Query groups:
- 6 count-only queries for
quotesLoaded(today/yesterday/this week/last week/this month/last month) —getList(1, 1, { fields: "id" }), readingtotalItems. - 8 count-only queries for
quoteVolumeTrend(one per week, reusing the samebuildWeekBuckets/isoWeekKeyhelpers already exported fromdashboard-service.ts— imported, not duplicated). - 1 list query for
outstandingQuotes(status IN (new, claimed),customer.rep = me,is_template != true, sorted bycreated,perPage: 8,fields: "id,quote_number",expand: "customer.customer")- its
totalItemsfor the count.
- its
- 1 list query for
outstandingOrders, built the same waygetOrderIdsForTabGroupdoes inorders-service.ts(order_items filtered by thenewtab group's statuses, chained withorder.customer.rep = me), then a follow-upordersfetch by the resulting ids fororder_number+expand.customer.customer, capped at 8, with the distinct-order count as the total. - 1 query for
monthRevenue—order_itemswhereorder.customer.rep = me && created >= <startOfMonth>,fields: "id,quantity,order_qty,selling_price,cost_price", summed in memory exactly likedashboard-service.ts's existingmonthRevenueloop (reusingquantity ?? order_qtyas the qty source for consistency).
No sampling, no SAMPLE_WINDOW_DAYS — every query here is already
scoped to one rep, so exact counts are cheap (a rep's customer set is
a small slice of the collection).
Route (/api/v1/rep-dashboard)
export async function GET(req: NextRequest) {
try {
const sessionUser = await getBrowserSessionUser(req);
const data = await getRepDashboardData(sessionUser.id);
return NextResponse.json(data);
} catch (error) { /* same error-shape pattern as /api/v1/dashboard */ }
}
Page (/dashboard2)
Client component, same fetch/realtime pattern as /dashboard
(DashboardPage in src/app/(app)/dashboard/page.tsx): fetch on
mount with the PB bearer token, debounce-refetch on quotes / orders
realtime events. Renders 3 rows using the existing chart
components (DashboardCard, KpiCard, BarChart, RadialGauge) —
no new chart components needed:
- Row 1: 3×
KpiCard— "Quotes today" (vs yesterday), "Quotes this week" (vs last week), "Quotes this month" (vs last month). Each reuses the existing delta-arrow + sparkline UI; the sparkline data is the 8-weekquoteVolumeTrendarray (last N points), so all three cards share one visual trend even though each headlines a different period. - Row 2, 3 cards:
- "Quote volume trend" —
BarChart, 8 weeks, single series (mirrors dashboard 1's "Order volume" card). - "Outstanding quotes" —
DashboardCardwith a compact list (reuses the visual pattern of dashboard 1'sFollowUpList: avatar-initial, quote #, customer, click →/quotes/[id]), count badge in the header. - "Outstanding orders" — same list pattern, click →
/orders/[id](viarouter.push, matching the existing orders page navigation).
- "Quote volume trend" —
- Row 3, 2 cards:
- "Selling vs Cost (this month)" —
BarChart, one category, two series (Selling / Cost), same component as dashboard 1's "Orders trend" card (New vs Fulfilled) just relabeled. - "Markup %" —
RadialGauge, value =cost, total =selling. This renders the ring as "cost's share of the selling price," which is always ≤100% when selling ≥ cost (the normal case) — unlike markup % itself ((selling-cost)/cost), which is unbounded and can exceed 100%, so it can't drive the ring directly. The gauge'scenterLabeloverrides the default value/total display and instead shows the literal${markupPct.toFixed(1)}%(the actual markup figure), withcenterSubLabel="Markup". Whencostis 0 (no orders yet this month,markupPct: null), render the existing "healthy empty state" pattern (seeStockAlerts's empty branch) instead of the gauge.
- "Selling vs Cost (this month)" —
No new nav entry is added in this pass — the page is reached by
direct URL (/dashboard2) for review purposes, matching "create
dashboard 2, see how it looks."
Error handling
Same pattern as every other /api/v1/* route in this codebase:
assertBrowserSession/getBrowserSessionUser throws a {status: 401}-tagged error on a missing/invalid token; the route handler
catches, reads .status, and returns NextResponse.json({error}, {status}). The page's fetchDashboard already has a try/catch +
toast.error — reused as-is (same code shape as dashboard/page.tsx
lines 85–115), no new error-handling design needed.
Division-by-zero on markup % (no orders this month) returns null
from the service; the page renders the empty state instead of NaN%
or Infinity%.
Testing
Mirrors the existing *-service.test.ts convention in
src/lib/server/ (e.g. boss-dashboard-service.test.ts,
orders-service.test.ts): a mocked PocketBase admin client (fake
getAdminPocketBase()) with getList/getFullList stubs returning
canned fixtures, asserting getRepDashboardData computes the right
counts/sums/deltas — including the zero-orders-this-month
(markupPct: null) edge case and the zero-quotes-any-period edge case
(deltas should read 0, not throw or divide by zero, matching the
existing ordersDelta/customersDelta "0 baseline → 0 or 100%"
convention already in dashboard/page.tsx).
No new tests needed for browser-session.ts beyond what
getBrowserSessionUser naturally inherits from the existing
assertBrowserSession test coverage pattern (if one exists) — a
thin pass-through over PB's auth-refresh endpoint.