docs: add design spec for rep dashboard (dashboard 2)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9cf8e39b65
commit
cc6f63dabb
1 changed files with 245 additions and 0 deletions
245
docs/superpowers/specs/2026-09-01-rep-dashboard-design.md
Normal file
245
docs/superpowers/specs/2026-09-01-rep-dashboard-design.md
Normal file
|
|
@ -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 = <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`:
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```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<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`)
|
||||
|
||||
```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.
|
||||
Loading…
Reference in a new issue