WFDASH-WEBAPP/src/lib/server/rep-dashboard-service.ts
Dave c9096dfd84 feat(dashboard2): trim Row 1 to Selling-only with last-month delta
- Drop Cost and Markup gauge — Selling is the headline the rep cares about
- monthRevenue is now {sellingThisMonth, sellingLastMonth}
- One combined PB query covers both months; classify each row by date in code so the delta costs zero extra round-trips
- RepKpiCard gains a format="currency" prop so Selling renders ZAR-formatted with a delta arrow (mirrors the quote-count cards)
- Row 1 grid adjusts from 6-col to 4-col to match the new card count
2026-09-01 01:55:52 +02:00

266 lines
No EOL
9.3 KiB
TypeScript

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 OUTSTANDING_ORDER_ITEMS_CAP = 200;
const MONTH_ORDER_ITEM_LIMIT = 1000;
type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;
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: {
sellingThisMonth: number;
sellingLastMonth: number;
};
}
/* ---------- 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<number> {
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<RepDashboardData> {
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, outstandingOrders probe, monthRevenue ----------
* These three queries are independent — fan them out in parallel so the
* page load is bounded by the slowest one (~80-150ms) instead of their
* sum (~300-500ms). The follow-up `orders` fetch for outstandingOrders
* stays sequential because it needs the deduped order ids from the probe.
*/
const [
outstandingQuotesResult,
outstandingOrderItems,
monthOrderItems,
] = await Promise.all([
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",
}),
pb.collection(getOrderItemsCollectionName()).getList<{ order?: string }>(
1,
OUTSTANDING_ORDER_ITEMS_CAP,
{
filter: `${orderRepFilter} && (${tabGroupFilter("new")})`,
fields: "order",
},
),
pb.collection(getOrderItemsCollectionName()).getList(1, MONTH_ORDER_ITEM_LIMIT, {
// Single query covers both months; classify each row by `created`
// in code so we don't pay a second round-trip just for the delta.
filter: `${orderRepFilter} && created >= "${lastMonthStart.toISOString()}" && created < "${nextMonthStart.toISOString()}"`,
fields: "id,created,quantity,order_qty,selling_price",
}),
]);
/* ---------- outstandingQuotes ---------- */
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 ---------- */
// Badge total = distinct order ids we saw in the capped probe. With
// 200 items and avg 1-3 items/order in the new tab, this covers ~70-200
// distinct orders — way more than the 8 we display. For a busy rep the
// count can understate true total, but the badge is "how many are on
// my plate right now" — capping to 200 keeps the probe to a single PB
// round-trip instead of paginating through every matching item.
const outstandingOrderIds = Array.from(
new Set(outstandingOrderItems.items.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 ---------- */
let sellingThisMonth = 0;
let sellingLastMonth = 0;
const thisMonthStartMs = thisMonthStart.getTime();
for (const it of monthOrderItems.items as unknown as Array<{
created?: string;
quantity?: number | null;
order_qty?: number | null;
selling_price?: number | null;
}>) {
const qty = Number(it.quantity ?? it.order_qty ?? 0);
const lineTotal = Number(it.selling_price ?? 0) * qty;
// `created` is an ISO string — compare on its parsed timestamp so we
// don't tie this to a particular date-string format PB happens to use.
const createdMs = it.created ? new Date(it.created).getTime() : 0;
if (createdMs >= thisMonthStartMs) {
sellingThisMonth += lineTotal;
} else {
sellingLastMonth += lineTotal;
}
}
return {
quotesLoaded: {
today: todayCount,
yesterday: yesterdayCount,
thisWeek: thisWeekCount,
lastWeek: lastWeekCount,
thisMonth: thisMonthCount,
lastMonth: lastMonthCount,
},
quoteVolumeTrend,
outstandingQuotes,
outstandingOrders,
monthRevenue: { sellingThisMonth, sellingLastMonth },
};
}