diff --git a/src/lib/server/rep-dashboard-service.test.ts b/src/lib/server/rep-dashboard-service.test.ts new file mode 100644 index 0000000..ba85aea --- /dev/null +++ b/src/lib/server/rep-dashboard-service.test.ts @@ -0,0 +1,222 @@ +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; + 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(() => Promise.resolve(hoisted.pbMock)), +})); + +const hoisted = vi.hoisted(() => { + const store: Record = {}; + return { store }; +}); + +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(); + }); +}); \ No newline at end of file diff --git a/src/lib/server/rep-dashboard-service.ts b/src/lib/server/rep-dashboard-service.ts new file mode 100644 index 0000000..293d334 --- /dev/null +++ b/src/lib/server/rep-dashboard-service.ts @@ -0,0 +1,240 @@ +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 }, + }; +} \ No newline at end of file