- 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.
120 lines
4.2 KiB
TypeScript
120 lines
4.2 KiB
TypeScript
/**
|
|
* Customer invoice numbers for an order, derived from its line items.
|
|
*
|
|
* An order has no invoice column of its own — invoicing happens per
|
|
* `order_items` row, and a partial pickup can put one order across
|
|
* several invoices. The number lives in one of two places depending on
|
|
* which flow raised the invoice:
|
|
*
|
|
* `order_item_details.invoice.number` — written by the Invoicing page
|
|
* (QuickBooks webhook). Carries the
|
|
* QB link, date and who set it.
|
|
* `customer_invoice_number` — written by
|
|
* `reconcileOrderItemInvoicedStatus`.
|
|
*
|
|
* `order_item_details` wins when both are set because it carries the QB
|
|
* metadata. Safe to import from client components.
|
|
*
|
|
* Careful: collection-confirm ALSO writes `order_item_details.invoice`,
|
|
* with the SUPPLIER's invoice number ("Confirmed for invoicing — IN-…"),
|
|
* before the customer is billed. QuickBooks' write-back later replaces
|
|
* it. Only an `invoice` blob carrying QuickBooks fields (`qbInvoiceId`,
|
|
* `qbInvoiceLink` or `billingCompany`) is a customer invoice.
|
|
*/
|
|
|
|
export type InvoiceBearingItem = {
|
|
id?: string;
|
|
customer_invoice_number?: string | null;
|
|
invoiced_date?: string | null;
|
|
order_item_details?: string | Record<string, unknown> | null;
|
|
};
|
|
|
|
export type OrderInvoiceSummary = {
|
|
number: string;
|
|
/** YYYY-MM-DD (or full ISO) when known, else null. */
|
|
date: string | null;
|
|
qbInvoiceLink: string | null;
|
|
billingCompany: string | null;
|
|
setByName: string | null;
|
|
/** How many of the order's lines went out on this invoice. */
|
|
lineCount: number;
|
|
};
|
|
|
|
type InvoiceDetails = {
|
|
number?: unknown;
|
|
date?: unknown;
|
|
qbInvoiceId?: unknown;
|
|
qbInvoiceLink?: unknown;
|
|
billingCompany?: unknown;
|
|
setByName?: unknown;
|
|
};
|
|
|
|
function str(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function readInvoiceDetails(
|
|
details: InvoiceBearingItem["order_item_details"],
|
|
): InvoiceDetails | null {
|
|
let parsed: unknown = details;
|
|
if (typeof details === "string") {
|
|
if (!details.trim()) return null;
|
|
try {
|
|
parsed = JSON.parse(details);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (!parsed || typeof parsed !== "object") return null;
|
|
const invoice = (parsed as { invoice?: unknown }).invoice;
|
|
if (!invoice || typeof invoice !== "object") return null;
|
|
const blob = invoice as InvoiceDetails;
|
|
// Supplier invoice from collection-confirm — not a customer invoice.
|
|
const fromQuickBooks = !!(str(blob.qbInvoiceId) || str(blob.qbInvoiceLink) || str(blob.billingCompany));
|
|
return fromQuickBooks ? blob : null;
|
|
}
|
|
|
|
/** The invoice number a single line went out on, or null. */
|
|
export function readItemInvoiceNumber(item: InvoiceBearingItem): string | null {
|
|
return (
|
|
str(readInvoiceDetails(item.order_item_details)?.number) ??
|
|
str(item.customer_invoice_number)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Distinct invoices across an order's lines, oldest first. Lines with no
|
|
* invoice number are ignored, so an order that isn't invoiced yet returns
|
|
* an empty array.
|
|
*/
|
|
export function summarizeOrderInvoices(
|
|
items: readonly InvoiceBearingItem[],
|
|
): OrderInvoiceSummary[] {
|
|
const byNumber = new Map<string, OrderInvoiceSummary>();
|
|
for (const item of items) {
|
|
const details = readInvoiceDetails(item.order_item_details);
|
|
const number = str(details?.number) ?? str(item.customer_invoice_number);
|
|
if (!number) continue;
|
|
const existing = byNumber.get(number);
|
|
if (existing) {
|
|
existing.lineCount += 1;
|
|
existing.date ??= str(details?.date) ?? str(item.invoiced_date);
|
|
existing.qbInvoiceLink ??= str(details?.qbInvoiceLink);
|
|
existing.billingCompany ??= str(details?.billingCompany);
|
|
existing.setByName ??= str(details?.setByName);
|
|
continue;
|
|
}
|
|
byNumber.set(number, {
|
|
number,
|
|
date: str(details?.date) ?? str(item.invoiced_date),
|
|
qbInvoiceLink: str(details?.qbInvoiceLink),
|
|
billingCompany: str(details?.billingCompany),
|
|
setByName: str(details?.setByName),
|
|
lineCount: 1,
|
|
});
|
|
}
|
|
return [...byNumber.values()].sort((a, b) =>
|
|
(a.date ?? "9999").localeCompare(b.date ?? "9999") ||
|
|
a.number.localeCompare(b.number),
|
|
);
|
|
}
|