feat(boss-dashboard): paginated rep activity feed for drill-down sheet
This commit is contained in:
parent
9aaeee25ec
commit
da1ccc48fd
2 changed files with 202 additions and 11 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue