WFDASH-WEBAPP/docs/superpowers/specs/2026-09-01-rep-dashboard-design.md
Dave cc6f63dabb docs: add design spec for rep dashboard (dashboard 2)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 01:25:53 +02:00

11 KiB
Raw Blame History

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

  1. 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 to customer.rep = <me>, excluding templates.
  • Outstanding orders: orders with at least one line item in the new tab group (see TAB_GROUP_ITEM_STATUSES.new in src/lib/item-status-ui.ts), scoped to customer.rep = <me>.
  • "Quotes loaded": counted by the quote's created date (not claimed-by date), scoped to customer.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_items lines whose parent order's customer.rep = <me>, filtered by the order_item's own created date falling in the current calendar month (matches dashboard-service.ts's existing monthRevenue calc, just rep-scoped). selling_price × quantity summed for "selling", cost_price × quantity summed 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, no user_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:

  1. 6 count-only queries for quotesLoaded (today/yesterday/this week/last week/this month/last month) — getList(1, 1, { fields: "id" }), reading totalItems.
  2. 8 count-only queries for quoteVolumeTrend (one per week, reusing the same buildWeekBuckets/isoWeekKey helpers already exported from dashboard-service.ts — imported, not duplicated).
  3. 1 list query for outstandingQuotes (status IN (new, claimed), customer.rep = me, is_template != true, sorted by created, perPage: 8, fields: "id,quote_number", expand: "customer.customer")
    • its totalItems for the count.
  4. 1 list query for outstandingOrders, built the same way getOrderIdsForTabGroup does in orders-service.ts (order_items filtered by the new tab group's statuses, chained with order.customer.rep = me), then a follow-up orders fetch by the resulting ids for order_number + expand.customer.customer, capped at 8, with the distinct-order count as the total.
  5. 1 query for monthRevenue — order_items where order.customer.rep = me && created >= <startOfMonth>, fields: "id,quantity,order_qty,selling_price,cost_price", summed in memory exactly like dashboard-service.ts's existing monthRevenue loop (reusing quantity ?? order_qty as 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-week quoteVolumeTrend array (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" — DashboardCard with a compact list (reuses the visual pattern of dashboard 1's FollowUpList: avatar-initial, quote #, customer, click → /quotes/[id]), count badge in the header.
    • "Outstanding orders" — same list pattern, click → /orders/[id] (via router.push, matching the existing orders page navigation).
  • 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's centerLabel overrides the default value/total display and instead shows the literal ${markupPct.toFixed(1)}% (the actual markup figure), with centerSubLabel="Markup". When cost is 0 (no orders yet this month, markupPct: null), render the existing "healthy empty state" pattern (see StockAlerts's empty branch) instead of the gauge.

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.