docs: add design spec for rep dashboard (dashboard 2)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-01 01:25:53 +02:00
parent 9cf8e39b65
commit cc6f63dabb

View 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.