WFDASH-WEBAPP/src/lib/server/boss-dashboard-service.ts
Dave 7555196166 feat(audit): implement audit trail for delivery notes and orders
- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field.
- Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability.
- Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications.
- Enhanced the `package.json` with a new migration script for setting up the audit log functionality.

This feature improves accountability and transparency in the management of delivery notes and orders.
2026-09-30 01:48:44 +02:00

485 lines
17 KiB
TypeScript

import { getAdminPocketBase } from "./pocketbase-admin";
export type BossWindow = "today" | "week" | "month";
export interface WindowRange {
rangeStart: string; // ISO
rangeEnd: string; // ISO
}
/**
* Computes the [start, end) UTC window for a boss-dashboard time tab.
* Today: midnight-to-midnight UTC.
* Week: Monday 00:00 UTC to next Monday 00:00 UTC.
* Month: day-1 00:00 UTC to day-1 of next month.
*
* All UTC because that's how PocketBase `created` timestamps compare.
*/
export function windowRange(window: BossWindow, now: Date = new Date()): WindowRange {
const start = new Date(now);
const end = new Date(now);
switch (window) {
case "today": {
start.setUTCHours(0, 0, 0, 0);
end.setUTCDate(end.getUTCDate() + 1);
end.setUTCHours(0, 0, 0, 0);
break;
}
case "week": {
// Monday = 1, Sunday = 0. Shift so Monday is start.
const day = start.getUTCDay();
const diffToMonday = day === 0 ? -6 : 1 - day;
start.setUTCDate(start.getUTCDate() + diffToMonday);
start.setUTCHours(0, 0, 0, 0);
end.setTime(start.getTime());
end.setUTCDate(end.getUTCDate() + 7);
break;
}
case "month": {
start.setUTCDate(1);
start.setUTCHours(0, 0, 0, 0);
end.setUTCMonth(end.getUTCMonth() + 1, 1);
end.setUTCHours(0, 0, 0, 0);
break;
}
}
return { rangeStart: start.toISOString(), rangeEnd: end.toISOString() };
}
/**
* PB filter clauses compare `created` (stored as "YYYY-MM-DD HH:mm:ss.sssZ")
* against the literal string given — a "T"-separated ISO string like
* `toISOString()` produces does not match and silently returns zero rows.
* Same fix as `order-delivery-note-link-service.ts` / `order-order-duplicate-link-service.ts`.
*/
export function toPbFilterDate(iso: string): string {
return iso.replace("T", " ");
}
// ---------------------------------------------------------------------------
// Boss dashboard aggregation
// ---------------------------------------------------------------------------
export interface BossRepRow {
id: string;
name: string;
isUnknown: boolean;
quotesLoaded: number;
quotesDoneFor: number;
quotesDoneBy: number;
ordersPlaced: number;
ordersReceived: number;
deliveryNotesCreated: number;
}
export interface BossDriverRow {
id: string;
name: string;
stopsToday: number;
stopsDone: number;
stopsLeft: number;
}
export interface BossDashboardData {
window: BossWindow;
rangeStart: string;
rangeEnd: string;
orderTotals: { total: number; delivered: number; invoiced: number };
reps: BossRepRow[];
drivers: BossDriverRow[];
}
/**
* Shape of the JSON the `/api/v1/boss-dashboard` route returns to the
* client. The service returns `BossDashboardData`; the route merges in
* `viewerName` from the auth-refresh response. Using a named alias here
* keeps the client cast honest instead of an inline intersection.
*/
export type BossDashboardResponse = BossDashboardData & {
viewerName: string | null;
};
/** Quote statuses that count as "the rep finished this one". */
export const DONE_STATUSES = ["emailed", "approved", "sent"];
/** Delivery-note statuses excluded from "created" counts — abandoned, not real activity. */
const EXCLUDED_DELIVERY_NOTE_STATUSES = new Set(["cancelled"]);
/** Driver statuses that count as a completed stop. */
const DRIVER_DONE_STATUSES = ["collected", "delivered", "invoiced"];
export async function getBossDashboardData({
window,
now = new Date(),
}: {
window: BossWindow;
now?: Date;
}): Promise<BossDashboardData> {
const { rangeStart, rangeEnd } = windowRange(window, now);
const todayRange = windowRange("today", now);
const pb = await getAdminPocketBase();
const rangeFilter = `created >= "${toPbFilterDate(rangeStart)}" && created < "${toPbFilterDate(rangeEnd)}"`;
const todayRangeFilter = `created >= "${toPbFilterDate(todayRange.rangeStart)}" && created < "${toPbFilterDate(todayRange.rangeEnd)}"`;
const [users, customers, orders, quotes, todayItems, orderItemsInWindow, deliveryNotes] =
await Promise.all([
pb.collection("users").getFullList({ fields: "id,name" }),
pb.collection("customers").getFullList({ fields: "id,rep" }),
pb.collection("orders").getFullList({
filter: rangeFilter,
fields: "id,rep,invoiced,item_status,created",
}),
pb.collection("quotes").getFullList({
filter: rangeFilter,
fields: "id,claimed_by,customer,status,created",
}),
// Driver stops are always today-only regardless of the active tab.
pb.collection("order_items").getFullList({
filter: todayRangeFilter,
fields: "id,drv_id,item_status,drv_status,drv_collected_at,created",
}),
// Delivered + cancelled are derived from order_items.item_status (the orders
// collection doesn't carry item_status in PB). Active window only. No data
// dependency on the other five — safe to parallelise.
pb.collection("order_items").getFullList({
filter: rangeFilter,
fields: "order,item_status",
}),
pb.collection("delivery_notes").getFullList({
filter: rangeFilter,
fields: "id,customer,status,created",
}),
]);
const userById = new Map<string, { id: string; name: string }>();
for (const u of users) {
userById.set(u.id, {
id: u.id,
name: String((u as Record<string, unknown>).name ?? ""),
});
}
const customerRepById = new Map<string, string>();
for (const c of customers) {
customerRepById.set(c.id, String((c as Record<string, unknown>).rep ?? ""));
}
// Build sets of order ids that have a delivered item vs a cancelled item.
// Definition: an order is "cancelled" if ANY of its order_items in the active
// window carries item_status="cancelled". This matches the spec: total counts
// orders placed in the window EXCLUDING cancelled ones. (orders.item_status
// doesn't exist in PB — the status lives on order_items.)
const deliveredOrderIds = new Set<string>();
const cancelledOrderIds = new Set<string>();
for (const item of orderItemsInWindow) {
const rec = item as Record<string, unknown>;
const orderId = String(rec.order ?? "").trim();
if (!orderId) continue;
const status = String(rec.item_status ?? "");
if (status === "delivered") deliveredOrderIds.add(orderId);
if (status === "cancelled") cancelledOrderIds.add(orderId);
}
const delivered = deliveredOrderIds.size;
const orderById = new Map<string, Record<string, unknown>>();
for (const o of orders) orderById.set(o.id, o as Record<string, unknown>);
// --- Order totals + per-rep order counts ---------------------------------
let total = 0;
let invoiced = 0;
const repOrders = new Map<string, number>();
for (const o of orders) {
const rec = o as Record<string, unknown>;
const isCancelled = cancelledOrderIds.has(o.id);
if (!isCancelled) total += 1;
if (rec.invoiced === true) invoiced += 1;
if (isCancelled) continue;
const { id: repId, unresolved } = resolveOrderRepId(rec, userById);
// Unresolved orders.rep values bucket into (unknown), matching how
// claimed_by handles unresolvable values. Empty rep → silently skip.
if (unresolved) {
repOrders.set("", (repOrders.get("") ?? 0) + 1);
} else if (repId) {
repOrders.set(repId, (repOrders.get(repId) ?? 0) + 1);
}
}
// --- Per-rep quote stats --------------------------------------------------
// quotesLoaded: claimed_by resolves to this rep
// quotesDoneFor: the quote's customer belongs to this rep
// quotesDoneBy: claimed_by resolves AND status is a done-status
const repRows = new Map<string, BossRepRow>();
const ensureRep = (id: string, name: string): BossRepRow => {
let row = repRows.get(id);
if (!row) {
row = {
id,
name,
isUnknown: id === "",
quotesLoaded: 0,
quotesDoneFor: 0,
quotesDoneBy: 0,
ordersPlaced: 0,
ordersReceived: 0,
deliveryNotesCreated: 0,
};
repRows.set(id, row);
}
return row;
};
// Seed every known rep so the UI always has a row, even with zero activity.
for (const u of userById.values()) ensureRep(u.id, u.name);
for (const q of quotes) {
const rec = q as Record<string, unknown>;
const status = String(rec.status ?? "");
const isDone = DONE_STATUSES.includes(status);
const customerRepId = customerRepById.get(String(rec.customer ?? "")) ?? "";
const claimedBy = String(rec.claimed_by ?? "").trim();
// doneFor: the customer's owning rep.
const owner = userById.get(customerRepId);
if (owner) ensureRep(owner.id, owner.name).quotesDoneFor += 1;
// loaded / doneBy: whoever claimed it.
if (claimedBy !== "") {
const claimer = userById.get(claimedBy);
const row = claimer
? ensureRep(claimer.id, claimer.name)
: ensureRep("", "(unknown)"); // unresolved claimed_by → single bucket
row.quotesLoaded += 1;
if (isDone) row.quotesDoneBy += 1;
}
}
for (const [repId, count] of repOrders) {
if (repId === "") {
// Unresolved orders.rep — bucket into (unknown).
ensureRep("", "(unknown)").ordersPlaced = count;
continue;
}
const u = userById.get(repId);
if (!u) continue;
ensureRep(u.id, u.name).ordersPlaced = count;
}
// --- Delivery notes created per rep -----------------------------------------
// Same attribution as quotesDoneFor: credited to the customer's owning rep,
// regardless of who in the office actually created the row.
for (const dn of deliveryNotes) {
const rec = dn as Record<string, unknown>;
if (EXCLUDED_DELIVERY_NOTE_STATUSES.has(String(rec.status ?? ""))) continue;
const customerRepId = customerRepById.get(String(rec.customer ?? "")) ?? "";
const owner = userById.get(customerRepId);
if (owner) ensureRep(owner.id, owner.name).deliveryNotesCreated += 1;
}
// --- Orders received (delivered) per rep -----------------------------------
// Same rep attribution as ordersPlaced above, restricted to orders that
// have a `delivered` item in the active window (deliveredOrderIds).
for (const orderId of deliveredOrderIds) {
const order = orderById.get(orderId);
if (!order) continue;
const { id: repId, unresolved } = resolveOrderRepId(order, userById);
if (unresolved) {
ensureRep("", "(unknown)").ordersReceived += 1;
} else if (repId) {
const u = userById.get(repId);
if (u) ensureRep(u.id, u.name).ordersReceived += 1;
}
}
// Sort: ordersPlaced DESC, quotesLoaded DESC, name ASC. Unknown always last.
const reps = Array.from(repRows.values())
.filter((r) => !r.isUnknown)
.sort(
(a, b) =>
b.ordersPlaced - a.ordersPlaced ||
b.quotesLoaded - a.quotesLoaded ||
a.name.localeCompare(b.name),
);
const unknownRow = repRows.get("");
if (
unknownRow &&
(unknownRow.quotesLoaded > 0 ||
unknownRow.quotesDoneBy > 0 ||
unknownRow.ordersPlaced > 0 ||
unknownRow.ordersReceived > 0 ||
unknownRow.deliveryNotesCreated > 0)
) {
reps.push(unknownRow);
}
// --- Driver stops ---------------------------------------------------------
// Driver stops use the today-only order_items fetch above (todayItems),
// NOT the active-window delivered fetch.
const driverStops = new Map<string, { id: string; stopsToday: number; stopsDone: number }>();
for (const item of todayItems) {
const rec = item as Record<string, unknown>;
const drvId = String(rec.drv_id ?? "").trim();
if (!drvId) continue;
const isDone = DRIVER_DONE_STATUSES.includes(String(rec.drv_status ?? ""));
const entry = driverStops.get(drvId) ?? { id: drvId, stopsToday: 0, stopsDone: 0 };
entry.stopsToday += 1;
if (isDone) entry.stopsDone += 1;
driverStops.set(drvId, entry);
}
const drivers: BossDriverRow[] = Array.from(driverStops.values()).map((d) => ({
id: d.id,
name: userById.get(d.id)?.name || "(unnamed driver)",
stopsToday: d.stopsToday,
stopsDone: d.stopsDone,
stopsLeft: Math.max(0, d.stopsToday - d.stopsDone),
}));
drivers.sort((a, b) => b.stopsLeft - a.stopsLeft || a.name.localeCompare(b.name));
return {
window,
rangeStart,
rangeEnd,
orderTotals: { total, delivered, invoiced },
reps,
drivers,
};
}
/**
* The boss only wants his active sales reps on this view. `users` also
* holds admin/test/driver/picker accounts, which the service seeds as
* zero-activity rep rows same as everyone else — filter them out at the
* route boundary (see `/api/v1/boss-dashboard/route.ts`), not here, so the
* tested aggregation math stays generic. Keyed by id (not name) so a
* display-name edit in PB admin doesn't silently drop someone off the list.
*/
export const ACTIVE_REP_IDS: ReadonlySet<string> = new Set([
"krpyec9lblra0qz", // Gary
"54uj39i4juqkh9x", // Tiaan
"wvfr6q8ppjnkkrs", // Chris
"j3sgntvcqpc7gt5", // Conar
"63zd0ys47pvs9w2", // Karin
"hlu4yn214ceu11b", // Pieter
]);
/**
* `orders.rep` is historically either a PB user id or a free-text name.
* Try the id lookup first, then a case-insensitive name match.
*
* Returns `{ id, unresolved }`:
* - `id !== ""` → matched a user; use `id`.
* - `id === ""` + `unresolved` → rep value present but didn't resolve any user;
* caller should bucket into `(unknown)`.
* - `id === ""` + !`unresolved` → rep value was empty; caller should skip.
*/
export function resolveOrderRepId(
order: Record<string, unknown>,
userById: Map<string, { id: string; name: string }>,
): { id: string; unresolved: boolean } {
const rep = String(order.rep ?? "").trim();
if (!rep) return { id: "", unresolved: false };
if (userById.has(rep)) return { id: rep, unresolved: false };
for (const u of userById.values()) {
if (u.name.trim().toLowerCase() === rep.toLowerCase()) return { id: u.id, unresolved: false };
}
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 >= "${toPbFilterDate(rangeStart)}" && created < "${toPbFilterDate(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 >= "${toPbFilterDate(rangeStart)}" && created < "${toPbFilterDate(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 };
}