diff --git a/scripts/audit-quote-company-drift.mjs b/scripts/audit-quote-company-drift.mjs new file mode 100644 index 0000000..9ac55e8 --- /dev/null +++ b/scripts/audit-quote-company-drift.mjs @@ -0,0 +1,89 @@ +/** + * READ-ONLY audit: quotes whose `company` relation disagrees with + * their customer's `company`. + * + * `quotes.company` is a denormalised copy of `customer.company` that + * PocketBase does not re-cascade, so any quote whose customer was + * swapped after creation is now inconsistent. This script only READS + * and prints — it makes no writes. + * + * Run: npx tsx scripts/audit-quote-company-drift.mjs + */ +import PocketBase from "pocketbase"; + +const PB_URL = process.env.PB_URL ?? "https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io"; +const PB_ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL ?? "david.els1@outlook.com"; +const PB_ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD ?? "1234567890"; + +const pb = new PocketBase(PB_URL); +await pb.collection("_superusers").authWithPassword(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD); + +const quotes = await pb.collection("quotes").getFullList(200, { + expand: "customer,customer.company,company", + sort: "-created", +}); + +// Company records store their display name in `company`, not `name`. +const nameOf = (expand, key) => expand?.[key]?.company ?? null; +// When an expand doesn't resolve, show the raw relation id rather than a +// bare "(none)" — otherwise "unresolved" and "genuinely empty" look identical. +const label = (name, id) => (name ? name : id ? `` : "(none)"); + +let checked = 0; +let consistent = 0; +let noCustomerCompany = 0; +const drifted = []; + +for (const q of quotes) { + if (!q.customer) continue; + checked++; + const cust = q.expand?.customer; + const custCompanyId = cust?.company ?? ""; + const custCompanyName = nameOf(cust?.expand, "company"); + + if (!custCompanyId) { + noCustomerCompany++; + if (q.company) { + drifted.push({ + n: q.quote_number, + id: q.id, + status: q.status, + quoteCompany: label(nameOf(q.expand, "company"), q.company), + custCompany: "(customer has none)", + created: (q.created ?? "").slice(0, 10), + }); + } + continue; + } + + if (q.company === custCompanyId) { + consistent++; + } else { + drifted.push({ + n: q.quote_number, + id: q.id, + status: q.status, + quoteCompany: label(nameOf(q.expand, "company"), q.company), + custCompany: custCompanyName ? label(custCompanyName, custCompanyId) : label(null, custCompanyId), + created: (q.created ?? "").slice(0, 10), + }); + } +} + +console.log(`Scanned : ${quotes.length} quotes`); +console.log(`With customer: ${checked}`); +console.log(`Consistent : ${consistent}`); +console.log(`No cust co : ${noCustomerCompany} (customer has no company)`); +console.log(`DRIFTED : ${drifted.length}`); +console.log(""); + +if (drifted.length) { + console.log("quote created status quote.company -> customer.company"); + console.log("-".repeat(84)); + for (const d of drifted.slice(0, 60)) { + console.log( + `${String(d.n).padEnd(10)} ${d.created} ${String(d.status).padEnd(18)} ${d.quoteCompany} -> ${d.custCompany}`, + ); + } + if (drifted.length > 60) console.log(`… and ${drifted.length - 60} more`); +} \ No newline at end of file diff --git a/src/app/(app)/quotes/[id]/page.tsx b/src/app/(app)/quotes/[id]/page.tsx index 8e7bfb9..9811a54 100644 --- a/src/app/(app)/quotes/[id]/page.tsx +++ b/src/app/(app)/quotes/[id]/page.tsx @@ -82,6 +82,7 @@ import { Loader2, Settings, Activity, + Truck, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -2028,6 +2029,58 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri const renderActions = () => { const btns: React.ReactNode[] = []; + /** + * Delivery-note quotes get a small, deliberately non-sending action + * row (2026-10-16). + * + * A DN quote shares its statuses with the regular pipeline, so it + * can't own a clean `switch` arm without duplicating three existing + * ones. Branch on the `delivery_note` relation before the switch. + * + * NOTE: there is deliberately NO "send" button here. A + * delivery-note quote is never sent on its own — it goes out with + * the note, via the delivery note's own "Send the delivery note and + * quote to the customer" button. The quote is created pre-approved + * (`createQuoteFromDeliveryNote`), so the rep approval queue never + * sees it. This row only points back at the note where Send lives, + * plus preview/re-price. An earlier draft of this fix added a + * "Send to customer" button that POSTed the quote directly — that + * would have created a second, competing way to email a customer + * the same invoice. + */ + if ( + quote.delivery_note && + (quote.status === "delivery_note" || + quote.status === "claimed" || + quote.status === "processed" || + quote.status === "approved") + ) { + return ( +
+ + + + + Sent together with the delivery note — not on its own. + +
+ ); + } + switch (quote.status) { case "new": btns.push( @@ -2072,6 +2125,8 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri // audit trail. // - Submit for Approval: PDF generation IS the submit. // The PDF route auto-flips to approval_pending on render. + // (Regular quotes only — delivery-note quotes are exempt + // from that flip and return early above.) btns.push(