From da1ccc48fd94be0b51ab81defb4a6e277c88e4bf Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 27 Aug 2026 23:58:19 +0200 Subject: [PATCH] feat(boss-dashboard): paginated rep activity feed for drill-down sheet --- src/lib/server/boss-dashboard-service.test.ts | 119 ++++++++++++++++-- src/lib/server/boss-dashboard-service.ts | 94 ++++++++++++++ 2 files changed, 202 insertions(+), 11 deletions(-) diff --git a/src/lib/server/boss-dashboard-service.test.ts b/src/lib/server/boss-dashboard-service.test.ts index dc51e00..a2c27c3 100644 --- a/src/lib/server/boss-dashboard-service.test.ts +++ b/src/lib/server/boss-dashboard-service.test.ts @@ -30,7 +30,7 @@ describe("windowRange", () => { }); import { vi, beforeEach } from "vitest"; -import { getBossDashboardData } from "./boss-dashboard-service"; +import { getBossDashboardData, getBossRepActivity } from "./boss-dashboard-service"; // Mock the admin PB client used by the service. // Pattern lifted from src/lib/server/dashboard-service.test.ts. @@ -82,14 +82,43 @@ const { pbMock, clearStore, addRow } = vi.hoisted(() => { const buildGetListFn = (name: string) => - async (opts?: { - filter?: string; - sort?: string; - perPage?: number; - page?: number; - expand?: string; - fields?: string; - }) => { + async ( + pageOrOpts?: + | number + | { + filter?: string; + sort?: string; + perPage?: number; + page?: number; + expand?: string; + fields?: string; + }, + perPageArg?: number, + optsArg?: { + filter?: string; + sort?: string; + perPage?: number; + page?: number; + expand?: string; + fields?: string; + }, + ) => { + // Normalise both call shapes: + // getList(opts) → single-arg + // getList(page, perPage, opts) → 3-arg (PB convention) + let page: number; + let perPage: number; + let opts: typeof optsArg; + if (typeof pageOrOpts === "number") { + page = pageOrOpts; + perPage = perPageArg ?? 30; + opts = optsArg; + } else { + opts = pageOrOpts; + page = opts?.page ?? 1; + perPage = opts?.perPage ?? 30; + } + let rows = Array.from(ensure(name).values()).filter((r) => matches(r, opts?.filter ?? ""), ); @@ -102,8 +131,6 @@ const { pbMock, clearStore, addRow } = vi.hoisted(() => { return desc ? bv.localeCompare(av) : av.localeCompare(bv); }); } - const page = opts?.page ?? 1; - const perPage = opts?.perPage ?? 30; const start = (page - 1) * perPage; return { items: rows.slice(start, start + perPage), @@ -386,3 +413,73 @@ describe("getBossDashboardData", () => { expect(data.orderTotals).toEqual({ total: 0, delivered: 0, invoiced: 0 }); }); }); + +describe("getBossRepActivity", () => { + it("returns paginated quotes for a rep, newest first", async () => { + addRow("users", { id: "rep-1", name: "Gary" }); + addRow("customers", { id: "cust-a", rep: "rep-1", name: "Acme" }); + for (let i = 1; i <= 30; i++) { + addRow("quotes", { + quote_number: `Q-${i}`, + claimed_by: "rep-1", + customer: "cust-a", + status: "emailed", + created: new Date(Date.UTC(2026, 7, 27, 10, i)).toISOString(), + }); + } + + const page1 = await getBossRepActivity({ + rep: "rep-1", + window: "today", + kind: "quotes", + page: 1, + now: fixedNow, + }); + + expect(page1.items.length).toBe(25); + expect(page1.hasMore).toBe(true); + expect(page1.items[0].number).toBe("Q-30"); // newest first + + const page2 = await getBossRepActivity({ + rep: "rep-1", + window: "today", + kind: "quotes", + page: 2, + now: fixedNow, + }); + + expect(page2.items.length).toBe(5); + expect(page2.hasMore).toBe(false); + }); + + it("returns paginated orders for a rep, newest first", async () => { + addRow("users", { id: "rep-1", name: "Gary" }); + addRow("orders", { order_number: "O-1", rep: "rep-1", invoiced: false, item_status: "pending", created: "2026-08-27T08:00:00.000Z" }); + addRow("orders", { order_number: "O-2", rep: "rep-1", invoiced: false, item_status: "pending", created: "2026-08-27T09:00:00.000Z" }); + addRow("orders", { order_number: "O-3", rep: "rep-2", invoiced: false, item_status: "pending", created: "2026-08-27T10:00:00.000Z" }); + + const data = await getBossRepActivity({ + rep: "rep-1", + window: "today", + kind: "orders", + page: 1, + now: fixedNow, + }); + + expect(data.items.length).toBe(2); + expect(data.items[0].number).toBe("O-2"); // newest first + expect(data.items[1].number).toBe("O-1"); + }); + + it("returns empty items when no matches", async () => { + const data = await getBossRepActivity({ + rep: "rep-1", + window: "today", + kind: "quotes", + page: 1, + now: fixedNow, + }); + expect(data.items).toEqual([]); + expect(data.hasMore).toBe(false); + }); +}); diff --git a/src/lib/server/boss-dashboard-service.ts b/src/lib/server/boss-dashboard-service.ts index 737e6ba..b160d05 100644 --- a/src/lib/server/boss-dashboard-service.ts +++ b/src/lib/server/boss-dashboard-service.ts @@ -307,3 +307,97 @@ function resolveOrderRepId( } return { id: "", unresolved: true }; } + +// --------------------------------------------------------------------------- +// Rep activity drill-down (paginated sheet per rep) +// --------------------------------------------------------------------------- + +export type RepActivityKind = "quotes" | "orders"; + +export interface RepActivityItem { + id: string; + number: string; + customer: string; + status: string; + ageMs: number; // relative to `now` +} + +export interface RepActivityPage { + items: RepActivityItem[]; + hasMore: boolean; +} + +const PAGE_SIZE = 25; + +/** + * Paginated rep activity feed for the drill-down sheet that opens when the + * boss taps a rep card. Returns 25 rows per page, newest first. + * + * - `kind: "quotes"` → quotes claimed by this rep in the active window. + * - `kind: "orders"` → orders placed by this rep in the active window. + * + * The window filter mirrors the active tab (today / week / month). + * `hasMore` is heuristic — true if the returned page is full, false otherwise. + */ +export async function getBossRepActivity({ + rep, + window, + kind, + page = 1, + now = new Date(), +}: { + rep: string; + window: BossWindow; + kind: RepActivityKind; + page?: number; + now?: Date; +}): Promise { + const { rangeStart, rangeEnd } = windowRange(window, now); + const pb = await getAdminPocketBase(); + + if (kind === "quotes") { + // Quotes owned by this rep = claimed_by = rep.id + const result = await pb.collection("quotes").getList(page, PAGE_SIZE, { + filter: `claimed_by = "${rep}" && created >= "${rangeStart}" && created < "${rangeEnd}"`, + sort: "-created", + fields: "id,quote_number,customer,status,created", + expand: "customer", + }); + + const items: RepActivityItem[] = (result.items as Array>).map((q) => { + const expanded = q.expand as { customer?: { customer?: string } } | undefined; + const customerName = expanded?.customer?.customer ?? ""; + return { + id: String(q.id), + number: String(q.quote_number ?? ""), + customer: customerName, + status: String(q.status ?? ""), + ageMs: now.getTime() - new Date(String(q.created)).getTime(), + }; + }); + + return { items, hasMore: items.length === PAGE_SIZE }; + } + + // kind === "orders" + const result = await pb.collection("orders").getList(page, PAGE_SIZE, { + filter: `rep = "${rep}" && created >= "${rangeStart}" && created < "${rangeEnd}"`, + sort: "-created", + fields: "id,order_number,customer,item_status,created", + expand: "customer", + }); + + const items: RepActivityItem[] = (result.items as Array>).map((o) => { + const expanded = o.expand as { customer?: { customer?: string } } | undefined; + const customerName = expanded?.customer?.customer ?? ""; + return { + id: String(o.id), + number: String(o.order_number ?? ""), + customer: customerName, + status: String(o.item_status ?? ""), + ageMs: now.getTime() - new Date(String(o.created)).getTime(), + }; + }); + + return { items, hasMore: items.length === PAGE_SIZE }; +}