From 5c210546e9c847f45b4b8956c897985da81ee554 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 1 Sep 2026 01:31:57 +0200 Subject: [PATCH] docs: add implementation plan for rep dashboard (dashboard 2) Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-09-01-rep-dashboard.md | 1118 +++++++++++++++++ 1 file changed, 1118 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-01-rep-dashboard.md diff --git a/docs/superpowers/plans/2026-09-01-rep-dashboard.md b/docs/superpowers/plans/2026-09-01-rep-dashboard.md new file mode 100644 index 0000000..3adbca7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-rep-dashboard.md @@ -0,0 +1,1118 @@ +# Rep Dashboard ("Dashboard 2") Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a new page at `/dashboard2` that shows the logged-in user their own quotes-loaded trend (with day/week/month deltas), their outstanding quotes and orders, and their month-to-date selling/cost/markup — all scoped to `customer.rep = `. The existing `/dashboard` is untouched. + +**Architecture:** One new server aggregation service (`rep-dashboard-service.ts`) doing narrow, count-only PocketBase queries scoped to one rep — no sampling, unlike the company-wide `dashboard-service.ts`. One new thin route. One new client page reusing the existing chart components (`DashboardCard`, `KpiCard`, `BarChart`, `RadialGauge`). One small additive change to `browser-session.ts` (a new export, existing export untouched) and one small additive prop on `RadialGauge` (optional, defaults preserve current behavior). + +**Tech Stack:** Next.js Client Components, PocketBase JS SDK, Vitest. + +**Reference:** design spec at `docs/superpowers/specs/2026-09-01-rep-dashboard-design.md`. + +**Correction from the spec during planning:** the spec said to reuse `buildWeekBuckets`/`isoWeekKey` "already exported from `dashboard-service.ts`" — they are in fact private (unexported) helpers in that file. Rather than exporting internals from a file the spec explicitly promised to leave untouched, this plan defines small local date-boundary helpers directly in `rep-dashboard-service.ts` (Task 3, Step 1). This keeps `dashboard-service.ts` completely unmodified, matching the spec's isolation goal even more strictly. + +--- + +## File Structure + +- **Modify:** `src/lib/server/browser-session.ts` — add `getBrowserSessionUser()` (additive). +- **Modify:** `src/components/charts/radial-gauge.tsx` — add optional `centerLabelOverride` prop (additive). +- **Create:** `src/lib/server/rep-dashboard-service.ts` — data aggregation. +- **Create:** `src/lib/server/rep-dashboard-service.test.ts` — unit tests. +- **Create:** `src/app/api/v1/rep-dashboard/route.ts` — route handler. +- **Create:** `src/app/(app)/dashboard2/page.tsx` — page. + +--- + +### Task 1: `getBrowserSessionUser` + +**Files:** +- Modify: `src/lib/server/browser-session.ts` +- Test: `src/lib/server/browser-session.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/lib/server/browser-session.test.ts +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { getBrowserSessionUser } from "./browser-session"; + +const ORIGINAL_FETCH = global.fetch; + +function makeRequest(authHeader: string | null): NextRequest { + const headers = new Headers(); + if (authHeader !== null) headers.set("authorization", authHeader); + return new NextRequest("http://localhost:4001/api/v1/rep-dashboard", { + headers, + }); +} + +describe("getBrowserSessionUser", () => { + beforeEach(() => { + global.fetch = ORIGINAL_FETCH; + }); + afterEach(() => { + global.fetch = ORIGINAL_FETCH; + }); + + it("throws a 401-tagged error when no token is present", async () => { + await expect(getBrowserSessionUser(makeRequest(null))).rejects.toMatchObject({ + status: 401, + }); + }); + + it("throws a 401-tagged error when PB rejects the token", async () => { + global.fetch = vi.fn(async () => new Response("", { status: 401 })) as unknown as typeof fetch; + await expect( + getBrowserSessionUser(makeRequest("Bearer bad-token")), + ).rejects.toMatchObject({ status: 401 }); + }); + + it("returns the resolved user's id and user_type on success", async () => { + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + token: "refreshed-token", + record: { id: "user123", user_type: ["REP"], name: "Test Rep" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as unknown as typeof fetch; + + const result = await getBrowserSessionUser(makeRequest("Bearer good-token")); + expect(result).toEqual({ id: "user123", user_type: ["REP"] }); + }); + + it("defaults user_type to null when the record omits it", async () => { + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ token: "t", record: { id: "user456" } }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as unknown as typeof fetch; + + const result = await getBrowserSessionUser(makeRequest("Bearer good-token")); + expect(result).toEqual({ id: "user456", user_type: null }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/server/browser-session.test.ts` +Expected: FAIL — `getBrowserSessionUser` is not exported yet. + +- [ ] **Step 3: Add the implementation** + +Modify `src/lib/server/browser-session.ts` — keep `assertBrowserSession` exactly as-is, add this below it: + +```typescript +export interface BrowserSessionUser { + id: string; + user_type: string[] | null; +} + +/** + * Same validation as `assertBrowserSession`, but returns the resolved + * PB user record instead of discarding it — for routes that need to + * scope queries to "whoever is logged in" (e.g. the rep dashboard). + */ +export async function getBrowserSessionUser( + req: NextRequest, +): Promise { + const authorization = req.headers.get("authorization") || ""; + const token = authorization.startsWith("Bearer ") + ? authorization.slice(7) + : authorization; + + if (!token) { + throw Object.assign(new Error("Not authenticated."), { status: 401 }); + } + + const pbUrl = + process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; + const response = await fetch( + `${pbUrl.replace(/\/$/, "")}/api/collections/users/auth-refresh`, + { + method: "POST", + headers: { Authorization: token }, + signal: AbortSignal.timeout(5_000), + }, + ); + + if (!response.ok) { + throw Object.assign(new Error("Invalid or expired session."), { + status: 401, + }); + } + + const body = (await response.json()) as { + record?: { id?: string; user_type?: string[] | null }; + }; + const id = body.record?.id; + if (!id) { + throw Object.assign(new Error("Invalid or expired session."), { + status: 401, + }); + } + + return { id, user_type: body.record?.user_type ?? null }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/lib/server/browser-session.test.ts` +Expected: PASS — all 4 assertions green. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/browser-session.ts src/lib/server/browser-session.test.ts +git commit -m "feat: add getBrowserSessionUser for routes that need the caller's identity" +``` + +--- + +### Task 2: `RadialGauge` center-label override + +**Files:** +- Modify: `src/components/charts/radial-gauge.tsx` + +No test — this component has no existing test coverage (matches the rest of `src/components/charts/`, which is all presentational and untested); the new prop is optional and additive, verified visually in Task 5. + +- [ ] **Step 1: Add the optional prop and use it in the center-text render** + +In `src/components/charts/radial-gauge.tsx`, change the prop destructuring (currently `value, total, color, label, sublabel, size = 180, ticks = 24`) to add `centerLabelOverride`: + +```typescript +export function RadialGauge({ + value, + total, + color, + label, + sublabel, + size = 180, + ticks = 24, + centerLabelOverride, +}: { + value: number; + total: number; + color: string; + label?: string; + sublabel?: string; + size?: number; + ticks?: number; + /** + * Overrides the big center text (normally the computed `value/total` + * percentage) with an arbitrary string — for callers where the ring's + * fraction and the headline number are different things (e.g. the rep + * dashboard's markup % gauge, where the ring shows cost's share of + * selling price but the headline is the unbounded markup percentage). + */ + centerLabelOverride?: string; +}) { +``` + +Then change the center-text render (the `` element currently containing `{pct}%`): + +```typescript + {/* Center percentage */} + + {centerLabelOverride ?? `${pct}%`} + +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/charts/radial-gauge.tsx +git commit -m "feat(charts): allow RadialGauge to override its center label" +``` + +--- + +### Task 3: `rep-dashboard-service.ts` + +**Files:** +- Create: `src/lib/server/rep-dashboard-service.ts` +- Test: `src/lib/server/rep-dashboard-service.test.ts` + +- [ ] **Step 1: Write the implementation** + +```typescript +// src/lib/server/rep-dashboard-service.ts +import { getAdminPocketBase } from "./pocketbase-admin"; +import { getOrderItemsCollectionName } from "./orders-service"; +import { tabGroupFilter } from "@/lib/item-status-ui"; + +const QUOTES_COLLECTION = "quotes"; +const ORDERS_COLLECTION = "orders"; + +const QUOTE_VOLUME_WEEKS = 8; +const OUTSTANDING_LIST_CAP = 8; +const MONTH_ORDER_ITEM_LIMIT = 1000; + +type AdminPocketBase = Awaited>; + +export interface RepDashboardData { + quotesLoaded: { + today: number; + yesterday: number; + thisWeek: number; + lastWeek: number; + thisMonth: number; + lastMonth: number; + }; + quoteVolumeTrend: { weekStart: string; count: number }[]; + outstandingQuotes: { + total: number; + items: { id: string; quote_number: string; customer_name: string | null }[]; + }; + outstandingOrders: { + total: number; + items: { id: string; order_number: string; customer_name: string | null }[]; + }; + monthRevenue: { + selling: number; + cost: number; + markupPct: number | null; + }; +} + +/* ---------- date helpers (Monday-anchored weeks, UTC) ---------- */ + +function startOfDayUtc(d: Date): Date { + const out = new Date(d); + out.setUTCHours(0, 0, 0, 0); + return out; +} + +function addDaysUtc(d: Date, days: number): Date { + const out = new Date(d); + out.setUTCDate(out.getUTCDate() + days); + return out; +} + +function startOfWeekUtc(d: Date): Date { + const out = startOfDayUtc(d); + const day = out.getUTCDay(); + const diff = day === 0 ? -6 : 1 - day; // Monday-anchored week + out.setUTCDate(out.getUTCDate() + diff); + return out; +} + +function startOfMonthUtc(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)); +} + +function addMonthsUtc(d: Date, months: number): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, 1)); +} + +/** Escapes a value for embedding inside a PB filter string literal. */ +function esc(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function rangeFilter(field: string, start: Date, end: Date): string { + return `${field} >= "${start.toISOString()}" && ${field} < "${end.toISOString()}"`; +} + +/** Count-only query — reads PB's own `totalItems` via a `perPage=1` page. */ +async function countWhere( + pb: AdminPocketBase, + collection: string, + filter: string, +): Promise { + const result = await pb.collection(collection).getList(1, 1, { + filter, + fields: "id", + }); + return result.totalItems || 0; +} + +export async function getRepDashboardData( + repId: string, + now: Date = new Date(), + pbArg?: AdminPocketBase, +): Promise { + const pb = pbArg ?? (await getAdminPocketBase()); + pb.autoCancellation(false); + + const repFilter = `customer.rep = "${esc(repId)}"`; + const orderRepFilter = `order.customer.rep = "${esc(repId)}"`; + + /* ---------- quotesLoaded (6 count-only queries) ---------- */ + const todayStart = startOfDayUtc(now); + const yesterdayStart = addDaysUtc(todayStart, -1); + const tomorrowStart = addDaysUtc(todayStart, 1); + const thisWeekStart = startOfWeekUtc(now); + const lastWeekStart = addDaysUtc(thisWeekStart, -7); + const nextWeekStart = addDaysUtc(thisWeekStart, 7); + const thisMonthStart = startOfMonthUtc(now); + const lastMonthStart = addMonthsUtc(thisMonthStart, -1); + const nextMonthStart = addMonthsUtc(thisMonthStart, 1); + + const quoteBaseFilter = `${repFilter} && is_template != true`; + + const [ + todayCount, + yesterdayCount, + thisWeekCount, + lastWeekCount, + thisMonthCount, + lastMonthCount, + ] = await Promise.all([ + countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", todayStart, tomorrowStart)}`), + countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", yesterdayStart, todayStart)}`), + countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", thisWeekStart, nextWeekStart)}`), + countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", lastWeekStart, thisWeekStart)}`), + countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", thisMonthStart, nextMonthStart)}`), + countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", lastMonthStart, thisMonthStart)}`), + ]); + + /* ---------- quoteVolumeTrend (8 weekly count-only queries) ---------- */ + const weekStarts: Date[] = []; + for (let i = QUOTE_VOLUME_WEEKS - 1; i >= 0; i--) { + weekStarts.push(addDaysUtc(thisWeekStart, -7 * i)); + } + const quoteVolumeTrend = await Promise.all( + weekStarts.map(async (weekStart) => { + const weekEnd = addDaysUtc(weekStart, 7); + const count = await countWhere( + pb, + QUOTES_COLLECTION, + `${quoteBaseFilter} && ${rangeFilter("created", weekStart, weekEnd)}`, + ); + return { weekStart: weekStart.toISOString().slice(0, 10), count }; + }), + ); + + /* ---------- outstandingQuotes ---------- */ + const outstandingQuotesResult = await pb + .collection(QUOTES_COLLECTION) + .getList(1, OUTSTANDING_LIST_CAP, { + filter: `${repFilter} && (status = "new" || status = "claimed") && is_template != true`, + sort: "-created", + fields: "id,quote_number", + expand: "customer", + }); + const outstandingQuotes = { + total: outstandingQuotesResult.totalItems || 0, + items: (outstandingQuotesResult.items as unknown as Array<{ + id: string; + quote_number: string; + expand?: { customer?: { customer?: string } }; + }>).map((q) => ({ + id: q.id, + quote_number: q.quote_number, + customer_name: q.expand?.customer?.customer ?? null, + })), + }; + + /* ---------- outstandingOrders ---------- */ + const outstandingOrderItems = await pb + .collection(getOrderItemsCollectionName()) + .getFullList<{ order?: string }>({ + filter: `${orderRepFilter} && (${tabGroupFilter("new")})`, + fields: "order", + }); + const outstandingOrderIds = Array.from( + new Set(outstandingOrderItems.map((it) => it.order).filter((id): id is string => !!id)), + ); + let outstandingOrdersItems: { id: string; order_number: string; customer_name: string | null }[] = []; + if (outstandingOrderIds.length > 0) { + const capped = outstandingOrderIds.slice(0, OUTSTANDING_LIST_CAP); + const idsFilter = capped.map((id) => `id = "${esc(id)}"`).join(" || "); + const ordersResult = await pb.collection(ORDERS_COLLECTION).getFullList<{ + id: string; + order_number: string; + expand?: { customer?: { customer?: string } }; + }>({ + filter: idsFilter, + sort: "-created", + fields: "id,order_number", + expand: "customer", + }); + outstandingOrdersItems = ordersResult.map((o) => ({ + id: o.id, + order_number: o.order_number, + customer_name: o.expand?.customer?.customer ?? null, + })); + } + const outstandingOrders = { + total: outstandingOrderIds.length, + items: outstandingOrdersItems, + }; + + /* ---------- monthRevenue ---------- */ + const monthOrderItems = await pb + .collection(getOrderItemsCollectionName()) + .getList(1, MONTH_ORDER_ITEM_LIMIT, { + filter: `${orderRepFilter} && ${rangeFilter("created", thisMonthStart, nextMonthStart)}`, + fields: "id,quantity,order_qty,selling_price,cost_price", + }); + let selling = 0; + let cost = 0; + for (const it of monthOrderItems.items as unknown as Array<{ + quantity?: number | null; + order_qty?: number | null; + selling_price?: number | null; + cost_price?: number | null; + }>) { + const qty = Number(it.quantity ?? it.order_qty ?? 0); + selling += Number(it.selling_price ?? 0) * qty; + cost += Number(it.cost_price ?? 0) * qty; + } + const markupPct = cost > 0 ? ((selling - cost) / cost) * 100 : null; + + return { + quotesLoaded: { + today: todayCount, + yesterday: yesterdayCount, + thisWeek: thisWeekCount, + lastWeek: lastWeekCount, + thisMonth: thisMonthCount, + lastMonth: lastMonthCount, + }, + quoteVolumeTrend, + outstandingQuotes, + outstandingOrders, + monthRevenue: { selling, cost, markupPct }, + }; +} +``` + +- [ ] **Step 2: Write the test file** + +```typescript +// src/lib/server/rep-dashboard-service.test.ts +import { describe, it, expect, vi } from "vitest"; +import { getRepDashboardData } from "./rep-dashboard-service"; + +// Local mock — same shape as dashboard-service.test.ts's, extended to +// treat dotted field names (e.g. "customer.rep", "order.customer.rep") +// as literal keys on the flat fixture objects. This doesn't model real +// PB relation traversal; it validates that the service builds the +// right filter clauses and aggregates correctly, with fixtures that +// pre-flatten the relation chain onto each row (e.g. an order_items +// fixture carries an "order.customer.rep" key directly). +type StoreMap = Map>; +const store: Record = {}; + +function seed(collection: string, rows: Record[]) { + const map: StoreMap = new Map(); + for (const row of rows) map.set(row.id as string, row); + store[collection] = map; +} + +/** + * Depth-aware filter evaluator. Unlike a naive `split("||")` / + * `split("&&")`, this respects parens (so `A && (B || C)` is parsed + * as AND(A, OR(B, C)), not mis-split into three top-level OR + * branches) and skips operators inside quoted string literals. + * Needed because this plan's queries mix top-level `&&` with a + * parenthesized `||` group (e.g. the outstanding-quotes/orders + * filters) — a naive splitter silently mis-evaluates that shape and + * would let a wrong-rep row match via a malformed leaf that "fails + * open" (see the comment on `evalLeaf` below). + */ +function splitTopLevel(expr: string, op: "||" | "&&"): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === '"') { + i++; + while (i < expr.length && expr[i] !== '"') i++; + continue; + } + if (c === "(") depth++; + else if (c === ")") depth--; + else if (depth === 0 && expr.startsWith(op, i)) { + parts.push(expr.slice(start, i).trim()); + i += op.length - 1; + start = i + 1; + } + } + parts.push(expr.slice(start).trim()); + return parts; +} + +function stripParens(expr: string): string { + const e = expr.trim(); + if (!e.startsWith("(") || !e.endsWith(")")) return e; + let depth = 0; + for (let i = 0; i < e.length; i++) { + if (e[i] === "(") depth++; + else if (e[i] === ")") { + depth--; + if (depth === 0 && i !== e.length - 1) return e; // not a single wrapping pair + } + } + return e.slice(1, -1).trim(); +} + +function evalLeaf(item: Record, leaf: string): boolean { + const m = leaf.match(/^([\w.]+)\s*(>=|<=|!=|=)\s*(?:"([^"]*)"|(\S+))$/); + if (!m) return true; // unrecognised leaf — fail open (matches everything) + const [, field, op, quoted, unquoted] = m; + const expected = quoted ?? unquoted ?? ""; + const actual = String(item[field] ?? ""); + switch (op) { + case "=": return actual === expected; + case "!=": return actual !== expected; + case ">=": return actual >= expected; + case "<=": return actual <= expected; + default: return true; + } +} + +function evalOr(item: Record, expr: string): boolean { + const parts = splitTopLevel(expr, "||"); + if (parts.length > 1) return parts.some((p) => evalOr(item, p)); + return evalAnd(item, expr); +} + +function evalAnd(item: Record, expr: string): boolean { + const parts = splitTopLevel(expr, "&&"); + if (parts.length > 1) return parts.every((p) => evalOr(item, p)); + const stripped = stripParens(expr); + if (stripped !== expr.trim()) return evalOr(item, stripped); + return evalLeaf(item, stripped); +} + +function where(collection: string, filter: string): Record[] { + const items = Array.from((store[collection] ?? new Map()).values()); + if (!filter || !filter.trim()) return items; + return items.filter((item) => evalOr(item, filter.trim())); +} + +const pbMock = { + autoCancellation: () => undefined, + collection: (name: string) => ({ + async getList(page = 1, perPage = 25, opts: { filter?: string } = {}) { + const all = where(name, opts.filter || ""); + const totalItems = all.length; + const start = (page - 1) * perPage; + return { + items: all.slice(start, start + perPage), + totalItems, + totalPages: Math.max(1, Math.ceil(totalItems / perPage)), + page, + perPage, + }; + }, + async getFullList(opts: { filter?: string } = {}) { + return where(name, opts.filter || ""); + }, + }), +}; + +vi.mock("./pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), +})); + +const REP_ID = "rep1"; +const OTHER_REP_ID = "rep2"; +const NOW = new Date("2026-08-20T12:00:00.000Z"); // a Thursday + +describe("getRepDashboardData", () => { + it("scopes quote counts to the given rep and computes day/week/month buckets", async () => { + seed("quotes", [ + { id: "q1", "customer.rep": REP_ID, is_template: false, created: "2026-08-20T09:00:00.000Z", status: "new", quote_number: "Q1" }, // today + { id: "q2", "customer.rep": REP_ID, is_template: false, created: "2026-08-19T09:00:00.000Z", status: "new", quote_number: "Q2" }, // yesterday + { id: "q3", "customer.rep": REP_ID, is_template: false, created: "2026-08-15T09:00:00.000Z", status: "new", quote_number: "Q3" }, // a prior week — not asserted on directly, just adds noise the filters must exclude from today/yesterday + { id: "q4", "customer.rep": OTHER_REP_ID, is_template: false, created: "2026-08-20T09:00:00.000Z", status: "new", quote_number: "Q4" }, // different rep + { id: "q5", "customer.rep": REP_ID, is_template: true, created: "2026-08-20T09:00:00.000Z", status: "new", quote_number: "Q5" }, // template, excluded + ]); + seed("order_items", []); + seed("orders", []); + + const data = await getRepDashboardData(REP_ID, NOW, pbMock as never); + + expect(data.quotesLoaded.today).toBe(1); + expect(data.quotesLoaded.yesterday).toBe(1); + expect(data.quoteVolumeTrend).toHaveLength(8); + }); + + it("only counts outstanding quotes in new/claimed status for this rep", async () => { + seed("quotes", [ + { id: "q1", "customer.rep": REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "new", quote_number: "Q1" }, + { id: "q2", "customer.rep": REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "claimed", quote_number: "Q2" }, + { id: "q3", "customer.rep": REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "complete", quote_number: "Q3" }, + { id: "q4", "customer.rep": OTHER_REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "new", quote_number: "Q4" }, + ]); + seed("order_items", []); + seed("orders", []); + + const data = await getRepDashboardData(REP_ID, NOW, pbMock as never); + + expect(data.outstandingQuotes.total).toBe(2); + expect(data.outstandingQuotes.items.map((i) => i.quote_number).sort()).toEqual(["Q1", "Q2"]); + }); + + it("counts outstanding orders as distinct orders with a 'new'-tab item for this rep", async () => { + seed("quotes", []); + seed("order_items", [ + { id: "i1", order: "o1", "order.customer.rep": REP_ID, item_status: "pending" }, + { id: "i2", order: "o1", "order.customer.rep": REP_ID, item_status: "processed" }, // same order, not new-tab — still counted once via i1 + { id: "i3", order: "o2", "order.customer.rep": REP_ID, item_status: "pending" }, + { id: "i4", order: "o3", "order.customer.rep": OTHER_REP_ID, item_status: "pending" }, // different rep + ]); + seed("orders", [ + { id: "o1", order_number: "ORD1" }, + { id: "o2", order_number: "ORD2" }, + ]); + + const data = await getRepDashboardData(REP_ID, NOW, pbMock as never); + + expect(data.outstandingOrders.total).toBe(2); + expect(data.outstandingOrders.items.map((i) => i.order_number).sort()).toEqual(["ORD1", "ORD2"]); + }); + + it("sums selling/cost and computes markup % for this month's order items", async () => { + seed("quotes", []); + seed("orders", []); + seed("order_items", [ + { id: "i1", "order.customer.rep": REP_ID, created: "2026-08-05T00:00:00.000Z", quantity: 2, selling_price: 100, cost_price: 50 }, + { id: "i2", "order.customer.rep": REP_ID, created: "2026-08-10T00:00:00.000Z", quantity: 1, selling_price: 40, cost_price: 20 }, + { id: "i3", "order.customer.rep": REP_ID, created: "2026-07-31T00:00:00.000Z", quantity: 1, selling_price: 999, cost_price: 1 }, // last month, excluded + { id: "i4", "order.customer.rep": OTHER_REP_ID, created: "2026-08-05T00:00:00.000Z", quantity: 1, selling_price: 999, cost_price: 1 }, // different rep + ]); + + const data = await getRepDashboardData(REP_ID, NOW, pbMock as never); + + // selling = 2*100 + 1*40 = 240; cost = 2*50 + 1*20 = 120 + expect(data.monthRevenue.selling).toBe(240); + expect(data.monthRevenue.cost).toBe(120); + expect(data.monthRevenue.markupPct).toBe(100); // (240-120)/120 * 100 + }); + + it("returns markupPct: null when there's no cost this month (no div-by-zero)", async () => { + seed("quotes", []); + seed("orders", []); + seed("order_items", []); + + const data = await getRepDashboardData(REP_ID, NOW, pbMock as never); + + expect(data.monthRevenue.selling).toBe(0); + expect(data.monthRevenue.cost).toBe(0); + expect(data.monthRevenue.markupPct).toBeNull(); + }); +}); +``` + +- [ ] **Step 3: Run the tests** + +Run: `npx vitest run src/lib/server/rep-dashboard-service.test.ts` +Expected: PASS — all 5 tests green. If `today`/`yesterday` counts don't match, double check `NOW`'s UTC date against the fixture timestamps (the test uses 2026-08-20T12:00:00Z as "today", 2026-08-19 as "yesterday" — adjust fixture dates to stay consistent if you change `NOW`). + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/server/rep-dashboard-service.ts src/lib/server/rep-dashboard-service.test.ts +git commit -m "feat: add rep-scoped dashboard data aggregation service" +``` + +--- + +### Task 4: `/api/v1/rep-dashboard` route + +**Files:** +- Create: `src/app/api/v1/rep-dashboard/route.ts` + +- [ ] **Step 1: Write the route** + +```typescript +// src/app/api/v1/rep-dashboard/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { getRepDashboardData } from "@/lib/server/rep-dashboard-service"; + +/** + * GET /api/v1/rep-dashboard + * + * Read model for `/dashboard2`. Scopes every query to the logged-in + * user's own id (via `customer.rep`) — unlike `/api/v1/dashboard`, + * there is no role gate: a non-rep just gets zeros everywhere. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + try { + const sessionUser = await getBrowserSessionUser(req); + const data = await getRepDashboardData(sessionUser.id); + return NextResponse.json(data); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load rep dashboard"; + const status = (error as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/app/api/v1/rep-dashboard/route.ts +git commit -m "feat: add /api/v1/rep-dashboard route" +``` + +--- + +### Task 5: `/dashboard2` page + +**Files:** +- Create: `src/app/(app)/dashboard2/page.tsx` + +- [ ] **Step 1: Write the page** + +```tsx +// src/app/(app)/dashboard2/page.tsx +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { + FileText, + ShoppingCart, + CalendarDays, + TrendingUp, + Gauge, + CheckCircle, + AlertCircle, +} from "lucide-react"; +import { toast } from "sonner"; +import getPocketBase from "@/lib/pocketbase"; +import { CHART, withAlpha } from "@/components/charts/palette"; +import { DashboardCard } from "@/components/charts/dashboard-card"; +import { BarChart } from "@/components/charts/bar-chart"; +import { RadialGauge } from "@/components/charts/radial-gauge"; +import type { RepDashboardData } from "@/lib/server/rep-dashboard-service"; + +function weekLabel(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString("en-ZA", { day: "numeric", month: "short" }); +} + +function deltaPct(current: number, previous: number): number { + if (previous === 0) return current > 0 ? 100 : 0; + return Math.round(((current - previous) / previous) * 100); +} + +function formatZar(value: number): string { + return new Intl.NumberFormat("en-ZA", { + style: "currency", + currency: "ZAR", + maximumFractionDigits: 0, + }).format(value); +} + +const REALTIME_DEBOUNCE_MS = 1_000; + +export default function RepDashboardPage() { + const router = useRouter(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + try { + const pb = getPocketBase(); + const pbToken = pb.authStore?.token; + const headers: Record = {}; + if (pbToken) headers.Authorization = pbToken; + + const response = await fetch("/api/v1/rep-dashboard", { + headers, + cache: "no-store", + }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error || `Rep dashboard failed (HTTP ${response.status})`); + } + setData((await response.json()) as RepDashboardData); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Failed to load rep dashboard"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + useEffect(() => { + const pb = getPocketBase(); + let handle = 0; + const trigger = () => { + if (handle) window.clearTimeout(handle); + handle = window.setTimeout(() => void fetchData(), REALTIME_DEBOUNCE_MS); + }; + const subscribe = async () => { + await pb.collection("quotes").subscribe("*", trigger); + await pb.collection("orders").subscribe("*", trigger); + await pb.collection("order_items").subscribe("*", trigger); + }; + void subscribe(); + return () => { + if (handle) window.clearTimeout(handle); + void pb.collection("quotes").unsubscribe("*"); + void pb.collection("orders").unsubscribe("*"); + void pb.collection("order_items").unsubscribe("*"); + }; + }, [fetchData]); + + if (loading || !data) { + return ( +
+ Loading your dashboard… +
+ ); + } + + const { quotesLoaded, quoteVolumeTrend, outstandingQuotes, outstandingOrders, monthRevenue } = data; + + return ( +
+
+

My Dashboard

+

+ Your quotes, outstanding work, and this month’s numbers. +

+
+ + {/* Row 1: quotes-loaded KPIs */} +
+ + + +
+ + {/* Row 2: trend + outstanding lists */} +
+ + ({ label: weekLabel(d.weekStart), values: [d.count] }))} + series={[{ label: "Quotes", color: CHART.orange }]} + height={180} + /> + + + + ({ id: q.id, primary: q.quote_number, secondary: q.customer_name }))} + emptyLabel="No outstanding quotes" + onClick={(id) => router.push(`/quotes/${id}`)} + /> + + + + ({ id: o.id, primary: o.order_number, secondary: o.customer_name }))} + emptyLabel="No outstanding orders" + onClick={(id) => router.push(`/orders/${id}`)} + /> + +
+ + {/* Row 3: this month's money */} +
+ + + + + + {monthRevenue.markupPct === null ? ( +
+ +

No orders yet this month

+
+ ) : ( + + )} +
+
+
+ ); +} + +function RepKpiCard({ + label, + value, + delta, + icon: Icon, + accentColor, +}: { + label: string; + value: number; + delta: number; + icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; + accentColor: string; +}) { + const positive = delta >= 0; + return ( + +
+
{label}
+
+ +
+
+
+ {value.toLocaleString()} +
+
+ {positive ? "+" : ""} + {delta}% vs previous +
+
+ ); +} + +function RepList({ + items, + emptyLabel, + onClick, +}: { + items: { id: string; primary: string; secondary: string | null }[]; + emptyLabel: string; + onClick: (id: string) => void; +}) { + if (items.length === 0) { + return ( +
+ +

{emptyLabel}

+
+ ); + } + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 2: Manual verification** + +1. Start the dev server and navigate to `/dashboard2` while logged in as a user tied to at least one customer as `rep`. +2. Confirm the 3 KPI cards show sensible today/week/month counts with deltas. +3. Confirm the quote-volume bar chart renders 8 weeks. +4. Confirm outstanding quotes/orders lists show only that rep's records, and clicking a row navigates to `/quotes/[id]` or `/orders/[id]`. +5. Confirm the Selling vs Cost bar chart and Markup % gauge render sensible numbers for orders created this month. If no orders exist this month, confirm the "No orders yet this month" empty state shows instead of a broken gauge. +6. Log in as a user with no customers assigned as rep — confirm every card shows zeros/empty states without erroring. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/\(app\)/dashboard2/page.tsx +git commit -m "feat: add /dashboard2 rep-scoped dashboard page" +``` + +--- + +### Task 6: Full regression pass + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full test suite** + +Run: `npx vitest run` +Expected: PASS — no regressions in existing suites, including `dashboard-service.test.ts` (untouched) and anything exercising `browser-session.ts`'s `assertBrowserSession` (untouched). + +- [ ] **Step 2: Confirm `/dashboard` is unaffected** + +Load `/dashboard` (the existing page) and confirm it looks and behaves exactly as before — no shared code was modified except the additive `RadialGauge` prop, which the existing "Approval rate" card doesn't pass, so its behavior is unchanged. + +- [ ] **Step 3: Commit (if any fixes were needed)** + +```bash +git add -A +git commit -m "test: regression pass for rep dashboard" +```