feat(boss-dashboard): paginated rep activity feed for drill-down sheet

This commit is contained in:
Dave 2026-08-27 23:58:19 +02:00
parent 9aaeee25ec
commit da1ccc48fd
2 changed files with 202 additions and 11 deletions

View file

@ -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);
});
});

View file

@ -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<RepActivityPage> {
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<Record<string, unknown>>).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<Record<string, unknown>>).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 };
}