/** * 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 = { 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; 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> = { 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): 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 = { web: "Dashboard", api: "Automation", n8n: "n8n", agent: "Agent API", mcp: "MCP agent", server: "Server", system: "System", };