- 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.
485 lines
17 KiB
TypeScript
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 };
|
|
}
|