WFDASH-WEBAPP/src/lib/audit-format.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

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",
};