168 lines
5.7 KiB
TypeScript
168 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",
|
||
|
|
};
|