diff --git a/docs/superpowers/specs/2026-09-01-rep-dashboard-design.md b/docs/superpowers/specs/2026-09-01-rep-dashboard-design.md new file mode 100644 index 0000000..54cbc8d --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-rep-dashboard-design.md @@ -0,0 +1,245 @@ +# 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 = `, 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 = `. +- **"Quotes loaded":** counted by the quote's `created` date (not + claimed-by date), scoped to `customer.rep = `, 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 = `, 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`: + +```typescript +export interface BrowserSessionUser { + id: string; + user_type: string[] | null; +} + +export async function getBrowserSessionUser( + req: NextRequest, +): Promise { + // 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) + +```typescript +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>, +): Promise +``` + +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 = ""` +(quotes, and orders via the direct `customer` relation) or +`order.customer.rep = ""` (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 >= `, `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`) + +```typescript +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.