- 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
266 lines
No EOL
9.3 KiB
TypeScript
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 },
|
|
};
|
|
} |