- 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.
167 lines
5.7 KiB
TypeScript
167 lines
5.7 KiB
TypeScript
/**
|
|
* Shared shapes + wording for the audit trail (quote / order / delivery
|
|
* note history). Rows are written by the PB hook
|
|
* pocketbase/pb_hooks/audit_log.pb.js and read by
|
|
* src/lib/server/audit-log-service.ts. Safe to import from client
|
|
* components.
|
|
*/
|
|
|
|
export type AuditParentType = "quote" | "order" | "delivery_note";
|
|
|
|
export type AuditChange = {
|
|
field: string;
|
|
from: unknown;
|
|
to: unknown;
|
|
/** Append-only text field: `to` holds just the appended part. */
|
|
appended?: boolean;
|
|
};
|
|
|
|
export type AuditEntry = {
|
|
id: string;
|
|
created: string;
|
|
collection: string;
|
|
recordId: string;
|
|
action: string;
|
|
label: string;
|
|
changes: AuditChange[];
|
|
actorName: string;
|
|
actorEmail: string;
|
|
source: string;
|
|
/**
|
|
* For price / markup edits on a quote: the number of the most recent
|
|
* quote PDF generated before this edit (1-based), or null when no PDF
|
|
* existed yet. Drives the "changed after PDF #n" flag.
|
|
*/
|
|
afterPdf: number | null;
|
|
};
|
|
|
|
export type AuditSummary = {
|
|
pdfCount: number;
|
|
priceChanges: number;
|
|
markupChanges: number;
|
|
/** Price / markup edits made after at least one PDF went out. */
|
|
changesAfterPdf: number;
|
|
editors: Array<{ name: string; count: number }>;
|
|
};
|
|
|
|
export type AuditTrail = {
|
|
/** False when the audit_log collection hasn't been created yet. */
|
|
enabled: boolean;
|
|
entries: AuditEntry[];
|
|
summary: AuditSummary;
|
|
};
|
|
|
|
export const PRICE_FIELDS = new Set(["cost_price", "selling_price"]);
|
|
export const MARKUP_FIELDS = new Set(["markup", "markup_percent"]);
|
|
export const MONEY_FIELDS = new Set(["cost_price", "selling_price", "total_amount"]);
|
|
|
|
const FIELD_LABELS: Record<string, string> = {
|
|
cost_price: "Cost price",
|
|
selling_price: "Selling price",
|
|
markup: "Markup",
|
|
markup_percent: "Markup",
|
|
quantity: "Qty",
|
|
order_qty: "Supplier qty",
|
|
description: "Description",
|
|
po_description: "PO description",
|
|
quote_description: "Quote description",
|
|
supplier: "Supplier",
|
|
status: "Status",
|
|
item_status: "Status",
|
|
customer: "Customer",
|
|
company: "Company",
|
|
company_letterhead: "Letterhead",
|
|
claimed_by: "Claimed by",
|
|
approved_by: "Approved by",
|
|
notes: "Notes",
|
|
quote_notes: "Quote notes",
|
|
is_processed: "Processed",
|
|
in_stock: "In stock",
|
|
order_number: "Order number",
|
|
quote_number: "Quote number",
|
|
delivery_note_number: "Delivery note",
|
|
customer_po: "Customer PO",
|
|
rfq_number: "RFQ number",
|
|
quote: "Linked quote",
|
|
order: "Linked order",
|
|
rep: "Rep",
|
|
invoiced: "Invoiced",
|
|
"order_item_details.invoice": "Invoice",
|
|
"order_item_details.print": "Printed",
|
|
customer_invoice_number: "Invoice number",
|
|
qty_collected: "Qty collected",
|
|
qty_delivered: "Qty delivered",
|
|
qty_invoiced: "Qty invoiced",
|
|
sku: "SKU",
|
|
unit: "Unit",
|
|
validity_days: "Validity (days)",
|
|
is_no_quote: "No-quote letter",
|
|
};
|
|
|
|
export function fieldLabel(field: string): string {
|
|
if (FIELD_LABELS[field]) return FIELD_LABELS[field];
|
|
const leaf = field.split(".").pop() || field;
|
|
const words = leaf.replace(/_/g, " ");
|
|
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
}
|
|
|
|
// Same format as `formatCurrency` in pb-helpers (not imported: that
|
|
// module pulls in the browser PB client).
|
|
function formatMoney(value: number): string {
|
|
return new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }).format(value);
|
|
}
|
|
|
|
/** Human-readable rendering of one side of a change. */
|
|
export function formatAuditValue(field: string, value: unknown): string {
|
|
if (value === null || value === undefined || value === "") return "—";
|
|
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
if (typeof value === "number") {
|
|
if (MONEY_FIELDS.has(field)) return formatMoney(value);
|
|
if (MARKUP_FIELDS.has(field)) return `${value}%`;
|
|
return String(value);
|
|
}
|
|
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
|
|
if (typeof value === "object") {
|
|
const obj = value as Record<string, unknown>;
|
|
if (field === "order_item_details.invoice" && typeof obj.number === "string") {
|
|
return obj.date ? `${obj.number} (${String(obj.date).slice(0, 10)})` : obj.number;
|
|
}
|
|
if (field === "order_item_details.print") return obj.printed ? "Yes" : "No";
|
|
return JSON.stringify(value);
|
|
}
|
|
const text = String(value);
|
|
// Status slugs read better as words.
|
|
if (field === "status" || field === "item_status") return text.replace(/_/g, " ");
|
|
return text;
|
|
}
|
|
|
|
const ACTION_VERBS: Record<string, Record<string, string>> = {
|
|
quotes: { create: "Created the quote", update: "Updated the quote", delete: "Deleted the quote" },
|
|
quote_items: { create: "Added line", update: "Edited line", delete: "Removed line" },
|
|
quote_attachments: { attachment_added: "Attached", delete: "Removed attachment" },
|
|
orders: { create: "Created the order", update: "Updated the order", delete: "Deleted the order" },
|
|
order_items: { create: "Added line", update: "Edited line", delete: "Removed line" },
|
|
delivery_notes: {
|
|
create: "Created the delivery note",
|
|
update: "Updated the delivery note",
|
|
delete: "Deleted the delivery note",
|
|
},
|
|
};
|
|
|
|
/** One-line headline for an entry, e.g. `Edited line "M16 hex bolt"`. */
|
|
export function describeAuditEntry(entry: Pick<AuditEntry, "collection" | "action" | "label">): string {
|
|
if (entry.action === "pdf_generated") return "Generated the quote PDF";
|
|
const verb = ACTION_VERBS[entry.collection]?.[entry.action] ?? `${entry.action} ${entry.collection}`;
|
|
const isHeader = entry.collection === "quotes" || entry.collection === "orders" || entry.collection === "delivery_notes";
|
|
return !isHeader && entry.label ? `${verb} "${entry.label}"` : verb;
|
|
}
|
|
|
|
export const SOURCE_LABELS: Record<string, string> = {
|
|
web: "Dashboard",
|
|
api: "Automation",
|
|
n8n: "n8n",
|
|
agent: "Agent API",
|
|
mcp: "MCP agent",
|
|
server: "Server",
|
|
system: "System",
|
|
};
|