diff --git a/AGENTS.md b/AGENTS.md
index 6fc8310..e2009b9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -331,6 +331,17 @@ The preview popup is REUSED, not rebuilt: the service emits
`SearchPreviewDialog` (`src/app/(app)/search/search-preview.tsx`), so
the manager sees the same quick-overview the desk sees on /search.
+# The POS counter never sells at R0
+
+The POS (`/api/v1/pos/catalog`) lists `stock_items` matching
+`POS_CATALOG_FILTER` in `pos-service.ts`: active **and** `selling_price > 0`.
+`checkoutSale` refuses any line with `unit_price <= 0`. Owner's rule
+(2026-10-30): only stock (and over-collected surplus) goes on the counter.
+Customer-order lines go to the `price_list` catalogue instead
+(`addRowsToPriceList`), never straight into `stock_items`. Any path that creates
+a stock item with a cost must price it with `applyDefaultMarkupToPatch`
+(80%); without a cost it stays off the till until someone prices it.
+
# Delivery Pool (`/delivery-pool`) — invoiced lines waiting to go out
Design: `docs/superpowers/specs/2026-10-30-delivery-pool-design.md`. Files:
@@ -350,13 +361,23 @@ routes under `/api/v1/delivery-pool` (browser session), page `src/app/(app)/deli
- Manual live step: `npm run pb:migrate:delivery-pool` before deploying (the service 503s
until the fields exist). The old `/deliveries` page stays until the office switches over.
-# The POS counter never sells at R0
+# Company letterhead lock — one quote letterhead per company
-The POS (`/api/v1/pos/catalog`) lists `stock_items` matching
-`POS_CATALOG_FILTER` in `pos-service.ts`: active **and** `selling_price > 0`.
-`checkoutSale` refuses any line with `unit_price <= 0`. Owner's rule
-(2026-10-30): only stock (and over-collected surplus) goes on the counter.
-Customer-order lines go to the `price_list` catalogue instead
-(`addRowsToPriceList`), never straight into `stock_items`. Any path that creates
-a stock item with a cost must price it with `applyDefaultMarkupToPatch`
-(80%); without a cost it stays off the till until someone prices it.
+Each company is quoted on one letterhead, stored on `companies.confirmed_letterhead`
+(CNL / ISHERWOOD, blank until confirmed). Rules: `src/lib/company-letterhead.ts`.
+The company is always the **customer's** company (`customer.company`), never
+`quotes.company` (that copy goes stale).
+
+- The process page (`process-letterhead-buttons.tsx`): blank → either button opens
+ "Set the default letterhead for {company}"; set → that button works, the other is
+ locked and only a triple-click renders on it (override for that quote only).
+- Enforced in `generateQuotePdf`: another letterhead without `override` returns
+ `letterhead_locked` (409 from `POST /api/v1/quotes/{id}/pdf`). So MCP / Giellie /
+ n8n can't render the wrong one either. An override sticks once rendered (a quote
+ already rendered on a letterhead may re-render on it).
+- With no letterhead passed, an unrendered quote takes the company's.
+- Changed on the company page via `PUT /api/v1/companies/{id}/letterhead`.
+- Manual live steps: `npm run pb:migrate:company-letterhead`, then dry-run and
+ `--apply` `scripts/2026-10-31-backfill-company-letterheads.ts` (fills companies
+ whose rendered quotes all used one letterhead). Until the migration runs the
+ lock is off and everything behaves as before.
diff --git a/package.json b/package.json
index e91bf46..aaa6a19 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"pb:migrate:collection-ai": "npx tsx scripts/migrations/2026-10-17-add-collection-ai.ts",
"pb:migrate:pool-pending": "npx tsx scripts/migrations/2026-10-30-add-stock-pool-pending.ts",
"pb:migrate:quote-email-to": "npx tsx scripts/migrations/2026-10-30-add-quote-email-to.ts",
+ "pb:migrate:company-letterhead": "npx tsx scripts/migrations/2026-10-31-add-company-letterhead.ts",
"pb:migrate:collection-auto-push": "npx tsx scripts/migrations/2026-10-28-add-collection-auto-push.ts",
"pb:migrate:order-assistant": "npx tsx scripts/migrations/2026-10-24-add-order-assistant-fields.ts",
"pb:migrate:po-number-unique": "npx tsx scripts/migrations/2026-10-19-po-number-unique-index.ts",
diff --git a/scripts/2026-10-31-backfill-company-letterheads.ts b/scripts/2026-10-31-backfill-company-letterheads.ts
new file mode 100644
index 0000000..233b6bd
--- /dev/null
+++ b/scripts/2026-10-31-backfill-company-letterheads.ts
@@ -0,0 +1,116 @@
+/**
+ * Pre-fill `companies.confirmed_letterhead` from quote history, so the
+ * office doesn't get the "set the default letterhead" popup for every
+ * company in the first week.
+ *
+ * For every company, counts the letterheads of its customers' quotes
+ * that were rendered (`pdf_generated_at` set). A company is filled only
+ * when ALL of its rendered quotes used one letterhead and there are at
+ * least MIN_QUOTES of them. Mixed companies are listed and left blank —
+ * the office confirms those on the next quote. Companies that already
+ * have a confirmed letterhead are never touched.
+ *
+ * Dry run by default (prints the plan). Run after
+ * `npm run pb:migrate:company-letterhead`:
+ *
+ * npx tsx scripts/2026-10-31-backfill-company-letterheads.ts
+ * npx tsx scripts/2026-10-31-backfill-company-letterheads.ts --apply
+ */
+import "./migrations/_env-loader";
+import PocketBase from "pocketbase";
+
+const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090";
+const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
+const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
+const APPLY = process.argv.includes("--apply");
+const MIN_QUOTES = 2;
+const CODES = ["CNL", "ISHERWOOD"] as const;
+
+type Row = {
+ company_letterhead?: string;
+ expand?: { customer?: { company?: string } };
+};
+
+async function main() {
+ if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
+ throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD env vars.");
+ }
+ const pb = new PocketBase(PB_URL);
+ pb.autoCancellation(false);
+ await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
+ console.log(`[letterhead-backfill] ${APPLY ? "APPLY" : "DRY RUN"} against ${PB_URL}`);
+
+ const companies = await pb
+ .collection("companies")
+ .getFullList<{ id: string; company: string; confirmed_letterhead?: string }>({
+ fields: "id,company,confirmed_letterhead",
+ });
+ if (companies.length > 0 && !("confirmed_letterhead" in companies[0])) {
+ throw new Error("companies.confirmed_letterhead is missing: run npm run pb:migrate:company-letterhead first.");
+ }
+ const byId = new Map(companies.map((c) => [c.id, c]));
+
+ const quotes = await pb.collection("quotes").getFullList({
+ filter: 'pdf_generated_at != ""',
+ expand: "customer",
+ fields: "company_letterhead,expand.customer.company",
+ });
+
+ const counts = new Map>();
+ for (const q of quotes) {
+ const companyId = q.expand?.customer?.company;
+ const code = q.company_letterhead;
+ if (!companyId || !code || !(CODES as readonly string[]).includes(code)) continue;
+ const c = counts.get(companyId) ?? {};
+ c[code] = (c[code] ?? 0) + 1;
+ counts.set(companyId, c);
+ }
+
+ const toSet: { id: string; name: string; code: string; n: number }[] = [];
+ const mixed: { name: string; c: Record }[] = [];
+ let tooFew = 0;
+ let alreadySet = 0;
+ for (const [companyId, c] of counts) {
+ const company = byId.get(companyId);
+ if (!company) continue;
+ if (company.confirmed_letterhead) {
+ alreadySet++;
+ continue;
+ }
+ const used = Object.keys(c);
+ if (used.length > 1) {
+ mixed.push({ name: company.company, c });
+ continue;
+ }
+ const n = c[used[0]];
+ if (n < MIN_QUOTES) {
+ tooFew++;
+ continue;
+ }
+ toSet.push({ id: companyId, name: company.company, code: used[0], n });
+ }
+
+ console.log(`\nRendered quotes read: ${quotes.length}; companies with history: ${counts.size}`);
+ console.log(`Will set: ${toSet.length} · mixed (left blank): ${mixed.length} · fewer than ${MIN_QUOTES} quotes: ${tooFew} · already set: ${alreadySet}`);
+ for (const code of CODES) {
+ console.log(` ${code}: ${toSet.filter((t) => t.code === code).length}`);
+ }
+ console.log("\nMixed companies (the office confirms these on the next quote):");
+ for (const m of mixed.sort((a, b) => a.name.localeCompare(b.name))) {
+ console.log(` ${m.name} — ${CODES.map((k) => `${k} ${m.c[k] ?? 0}`).join(", ")}`);
+ }
+
+ if (!APPLY) {
+ console.log("\nDry run: nothing written. Re-run with --apply to set them.");
+ return;
+ }
+ for (const t of toSet) {
+ await pb.collection("companies").update(t.id, { confirmed_letterhead: t.code });
+ }
+ console.log(`\nSet ${toSet.length} companies.`);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/2026-10-31-clean-deliveries-board.ts b/scripts/2026-10-31-clean-deliveries-board.ts
new file mode 100644
index 0000000..ae0734e
--- /dev/null
+++ b/scripts/2026-10-31-clean-deliveries-board.ts
@@ -0,0 +1,137 @@
+/**
+ * Clear the old /deliveries board before the office switches to the
+ * Delivery Pool (owner's call, 2026-10-31: pool starts at 2026-10-28,
+ * everything older counts as delivered).
+ *
+ * Sets `item_status = "completed"` on:
+ *
+ * A. every `checked_in` line (back from the driver; the n8n job that
+ * was meant to complete them never shipped)
+ * B. every `with_driver` line (loaded on a driver in October)
+ * C. every `invoiced` line that is NOT left for the pool:
+ * - its delivery note is already `completed` / `cancelled`
+ * (POS counter sales — the paper is signed), or
+ * - it was invoiced before POOL_START, or has no invoice date.
+ *
+ * Left alone: `invoiced` lines on/after POOL_START with no closed DN (they
+ * show in the Delivery Pool), supplier-side lines (`exclude_from_invoice`),
+ * and delivery notes (open DNs are listed for a person to check).
+ *
+ * `delivered_at` is NOT stamped — the real delivery date is unknown. The
+ * audit hook records the change as "Deliveries board cleanup".
+ *
+ * Run:
+ * npx tsx scripts/2026-10-31-clean-deliveries-board.ts # dry-run
+ * npx tsx scripts/2026-10-31-clean-deliveries-board.ts --apply # write
+ * ... --report=/path/report.csv # CSV of every line
+ *
+ * Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL,
+ * PB_ADMIN_PASSWORD.
+ */
+
+import "./_env"; // side-effect import: loads .env.local before anything else
+
+import { writeFileSync } from "node:fs";
+import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
+import { runWithAuditActor } from "../src/lib/server/audit-context";
+import { DELIVERY_POOL_START } from "../src/lib/delivery-pool";
+
+const POOL_START = "2026-10-28";
+const CLOSED_DN = new Set(["completed", "cancelled"]);
+
+type Line = {
+ id: string;
+ item_status: string;
+ invoiced_date: string;
+ customer_invoice_number: string;
+ delivery_note_number: string;
+ exclude_from_invoice: boolean;
+ description: string;
+};
+
+type Reason = "checked_in" | "with_driver" | "dn_closed" | "before_pool" | "no_date";
+
+const apply = process.argv.includes("--apply");
+const reportArg = process.argv.find((a) => a.startsWith("--report="));
+const reportPath = reportArg ? reportArg.slice("--report=".length) : null;
+
+function reasonFor(line: Line, dnStatus: Map): Reason | null {
+ if (line.exclude_from_invoice) return null;
+ if (line.item_status === "checked_in") return "checked_in";
+ if (line.item_status === "with_driver") return "with_driver";
+ if (line.item_status !== "invoiced") return null;
+ const dn = line.delivery_note_number ? dnStatus.get(line.delivery_note_number) : undefined;
+ if (dn && CLOSED_DN.has(dn)) return "dn_closed";
+ const day = (line.invoiced_date || "").slice(0, 10);
+ if (!day) return "no_date";
+ return day < POOL_START ? "before_pool" : null;
+}
+
+async function main() {
+ if (DELIVERY_POOL_START !== POOL_START) {
+ throw new Error(
+ `DELIVERY_POOL_START is ${DELIVERY_POOL_START}; set it to ${POOL_START} first or lines between the two dates fall through the gap.`,
+ );
+ }
+ const pb = await getAdminPocketBase();
+ const lines = await pb.collection("order_items").getFullList({
+ filter: `item_status = "checked_in" || item_status = "with_driver" || item_status = "invoiced"`,
+ fields: "id,item_status,invoiced_date,customer_invoice_number,delivery_note_number,exclude_from_invoice,description",
+ });
+ const dns = await pb.collection("delivery_notes").getFullList<{ delivery_note_number: string; status: string; created: string }>({
+ fields: "delivery_note_number,status,created",
+ });
+ const dnStatus = new Map(dns.map((d) => [d.delivery_note_number, d.status]));
+
+ const plan = lines.map((line) => ({ line, reason: reasonFor(line, dnStatus) }));
+ const todo = plan.filter((p) => p.reason !== null);
+ const counts = new Map }>();
+ for (const p of plan) {
+ const key = p.reason ?? (p.line.exclude_from_invoice ? "keep:supplier_side" : "keep:pool");
+ const c = counts.get(key) ?? { lines: 0, invoices: new Set() };
+ c.lines += 1;
+ c.invoices.add(p.line.customer_invoice_number || `(none:${p.line.id})`);
+ counts.set(key, c);
+ }
+ console.log(`[deliveries-cleanup] ${apply ? "APPLY" : "DRY-RUN"} — pool starts ${POOL_START}`);
+ for (const [k, c] of [...counts].sort()) {
+ console.log(` ${k.padEnd(20)} lines ${String(c.lines).padStart(5)} invoices ${c.invoices.size}`);
+ }
+ console.log(` → ${todo.length} lines to mark completed`);
+
+ const openDns = dns
+ .filter((d) => d.status === "draft" || d.status === "sent" || d.status === "loaded")
+ .sort((a, b) => a.created.localeCompare(b.created));
+ console.log(` Open delivery notes (not touched): ${openDns.map((d) => `${d.delivery_note_number} ${d.status}`).join(", ")}`);
+
+ if (reportPath) {
+ const rows = plan.map((p) =>
+ [p.line.id, p.line.item_status, p.reason ?? "keep", p.line.customer_invoice_number, p.line.delivery_note_number,
+ (p.line.invoiced_date || "").slice(0, 10), JSON.stringify(p.line.description ?? "")].join(","),
+ );
+ writeFileSync(reportPath, ["id,status,action,invoice,dn,invoiced_date,description", ...rows].join("\n"));
+ console.log(` Report: ${reportPath}`);
+ }
+
+ if (!apply) return;
+ let done = 0;
+ let failed = 0;
+ await runWithAuditActor({ name: "Deliveries board cleanup" }, async () => {
+ for (const p of todo) {
+ try {
+ await pb.collection("order_items").update(p.line.id, { item_status: "completed" });
+ done += 1;
+ } catch (err) {
+ failed += 1;
+ console.error(` ✗ ${p.line.id}: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ if (done % 200 === 0 && done > 0) console.log(` … ${done}/${todo.length}`);
+ }
+ });
+ console.log(`[deliveries-cleanup] completed ${done}, failed ${failed}`);
+}
+
+main().catch((err) => {
+ console.error("[deliveries-cleanup] failed:", err);
+ process.exit(1);
+});
diff --git a/scripts/migrations/2026-10-31-add-company-letterhead.ts b/scripts/migrations/2026-10-31-add-company-letterhead.ts
new file mode 100644
index 0000000..3ab8c25
--- /dev/null
+++ b/scripts/migrations/2026-10-31-add-company-letterhead.ts
@@ -0,0 +1,60 @@
+/**
+ * One-shot migration: add `confirmed_letterhead` to the live
+ * `companies` collection (idempotent).
+ *
+ * The company's quote letterhead (CNL or ISHERWOOD, blank until
+ * confirmed). Once set, the quote process page only lets the matching
+ * letterhead button through without an override, and the quote PDF
+ * service refuses the other one. See `src/lib/company-letterhead.ts`.
+ *
+ * Until this has run, the lock is off: the app treats a company record
+ * without the field as "no lock" and behaves as before.
+ *
+ * Run with:
+ *
+ * npm run pb:migrate:company-letterhead
+ *
+ * Safe to run multiple times.
+ */
+import "./_env-loader";
+import PocketBase from "pocketbase";
+
+const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090";
+const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
+const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
+
+async function main() {
+ if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
+ throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD env vars.");
+ }
+
+ const pb = new PocketBase(PB_URL);
+ await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
+ console.log(`[confirmed_letterhead] Authenticated against ${PB_URL}`);
+
+ const collection = await pb.collections.getOne("companies");
+ if (collection.fields.some((f: { name: string }) => f.name === "confirmed_letterhead")) {
+ console.log("[confirmed_letterhead] Field already exists, nothing to do.");
+ return;
+ }
+
+ await pb.collections.update(collection.id, {
+ fields: [
+ ...collection.fields,
+ {
+ name: "confirmed_letterhead",
+ type: "select",
+ required: false,
+ presentable: false,
+ maxSelect: 1,
+ values: ["CNL", "ISHERWOOD"],
+ },
+ ],
+ });
+ console.log("[confirmed_letterhead] Field added to companies.");
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/src/app/(app)/companies/[id]/page.tsx b/src/app/(app)/companies/[id]/page.tsx
index f073be2..4390466 100644
--- a/src/app/(app)/companies/[id]/page.tsx
+++ b/src/app/(app)/companies/[id]/page.tsx
@@ -11,6 +11,12 @@ import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { getCompany, formatDate } from "@/lib/pb-helpers";
import type { Company } from "@/lib/types";
+import {
+ confirmedLetterheadOf,
+ QUOTE_LETTERHEAD_CODES,
+ QUOTE_LETTERHEAD_LABELS,
+ type QuoteLetterheadCode,
+} from "@/lib/company-letterhead";
/**
* Treat PB's `{ lon: 0, lat: 0 }` (Null Island) as "no coordinates".
@@ -64,6 +70,33 @@ export default function CompanyDetailPage({ params }: { params: Promise<{ id: st
fetchCompany();
}, [fetchCompany]);
+ const [savingLetterhead, setSavingLetterhead] = useState(false);
+ const letterhead = confirmedLetterheadOf(company);
+
+ // The company's quote letterhead (src/lib/company-letterhead.ts):
+ // the only button that works on its quotes' process page.
+ const saveLetterhead = async (code: QuoteLetterheadCode | null) => {
+ setSavingLetterhead(true);
+ try {
+ const res = await fetch(`/api/v1/companies/${id}/letterhead`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: getPocketBase().authStore.token,
+ },
+ body: JSON.stringify({ letterhead: code }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
+ setCompany((prev) => (prev ? { ...prev, confirmed_letterhead: code ?? "" } : prev));
+ toast.success(code ? `Quotes for this company use ${QUOTE_LETTERHEAD_LABELS[code]}` : "Letterhead cleared — the next quote will ask");
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : "Failed to save the letterhead");
+ } finally {
+ setSavingLetterhead(false);
+ }
+ };
+
const handleDelete = async () => {
try {
const pb = getPocketBase();
@@ -207,6 +240,40 @@ export default function CompanyDetailPage({ params }: { params: Promise<{ id: st
+ {letterhead !== undefined && (
+
+
+ Quote letterhead
+
+
+
+ {letterhead
+ ? `Quotes for this company are locked to ${QUOTE_LETTERHEAD_LABELS[letterhead]}.`
+ : "Not confirmed yet — the next quote's PDF button will ask."}
+
+
+
+ )}
+
Created
diff --git a/src/app/(app)/quotes/[id]/process/page.tsx b/src/app/(app)/quotes/[id]/process/page.tsx
index ff0dcc8..797f08d 100644
--- a/src/app/(app)/quotes/[id]/process/page.tsx
+++ b/src/app/(app)/quotes/[id]/process/page.tsx
@@ -22,7 +22,6 @@ import { SupplierSearch } from "@/components/ui/supplier-search";
import {
ArrowLeft,
FileText,
- FileDown,
Save,
Trash2,
RefreshCw,
@@ -62,6 +61,11 @@ import { QuotedBeforePanel } from "@/components/quoted-before-panel";
import type { Quote, QuoteItem, QuoteAttachment, Supplier, Customer } from "@/lib/types";
import { cn } from "@/lib/utils";
import type { LetterheadCode } from "@/lib/letterhead";
+import { confirmedLetterheadOf, type QuoteLetterheadCode } from "@/lib/company-letterhead";
+import {
+ ProcessLetterheadButtons,
+ type GenerateLetterheadOptions,
+} from "@/components/quotes/process-letterhead-buttons";
export default function QuoteProcessPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
@@ -435,6 +439,37 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
}
};
+ // The customer's company and its confirmed letterhead (the lock —
+ // see src/lib/company-letterhead.ts). Updated locally after the
+ // popup saves a default so the buttons lock without a refetch.
+ const letterheadCompany = quote?.expand?.customer?.expand?.company as
+ | { id: string; company?: string; confirmed_letterhead?: unknown }
+ | undefined;
+ // A placeholder customer gets no lock and no popup: the PDF route
+ // refuses it until the real customer is picked anyway.
+ const companyLetterhead =
+ quote?.expand?.customer && isPlaceholderCustomer(quote.expand.customer)
+ ? undefined
+ : confirmedLetterheadOf(letterheadCompany);
+ const setCompanyLetterhead = (code: QuoteLetterheadCode | null) =>
+ setQuote((prev) => {
+ const company = prev?.expand?.customer?.expand?.company;
+ if (!prev || !company) return prev;
+ return {
+ ...prev,
+ expand: {
+ ...prev.expand,
+ customer: {
+ ...prev.expand!.customer!,
+ expand: {
+ ...prev.expand!.customer!.expand,
+ company: { ...company, confirmed_letterhead: code ?? "" },
+ },
+ },
+ },
+ } as Quote;
+ });
+
/**
* POST /api/v1/quotes/[id]/pdf to render + auto-save the PDF, then
* open the in-app viewer modal. Refreshes local state so the status
@@ -444,17 +479,24 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
* returns a structured 422 with `code` + `details.issues`. We surface
* that as a richer toast so the office knows exactly what to fix.
*/
- const generatePdf = async (letterhead: LetterheadCode) => {
+ const generatePdf = async (letterhead: LetterheadCode, options?: GenerateLetterheadOptions) => {
if (generatingPdf) return;
setGeneratingPdf(letterhead);
try {
const res = await fetch(`/api/v1/quotes/${id}/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ letterhead }),
+ body: JSON.stringify({ letterhead, ...options }),
});
const data = await res.json();
if (!res.ok || !data.ok) {
+ // The company is confirmed on the other letterhead (someone set
+ // it since this page loaded): show it and lock the buttons.
+ if (data.code === "letterhead_locked") {
+ setCompanyLetterhead(data.details?.confirmed ?? null);
+ toast.error(data.error, { duration: 8000 });
+ return;
+ }
// Structured validation error — show the operator what to fix.
if (data.code === "placeholder_customer") {
toast.error(
@@ -485,6 +527,7 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
attachmentId: data.attachmentId,
filename: data.filename,
});
+ if (options?.setCompanyDefault) setCompanyLetterhead(letterhead as QuoteLetterheadCode);
// Update local status + claimed_by so the badge / "Claimed by"
// chip reflect the new state immediately. Auto-claim on the
// server may have written `claimed_by` when it was empty —
@@ -1810,35 +1853,15 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
: `Price every item + assign a supplier before generating (${missingPricing} unpriced, ${missingSupplier} missing supplier)`}
-
-
-
-
+ generatePdf(code, options)}
+ />
diff --git a/src/app/api/v1/companies/[id]/letterhead/route.ts b/src/app/api/v1/companies/[id]/letterhead/route.ts
new file mode 100644
index 0000000..39e95c1
--- /dev/null
+++ b/src/app/api/v1/companies/[id]/letterhead/route.ts
@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getBrowserSessionUser } from "@/lib/server/browser-session";
+import { setCompanyLetterhead } from "@/lib/server/company-letterhead-service";
+
+/**
+ * PUT /api/v1/companies/[id]/letterhead
+ *
+ * Body: `{ letterhead: "CNL" | "ISHERWOOD" | null }` — the company's
+ * confirmed quote letterhead (null clears it, so the next quote asks
+ * again). See `src/lib/company-letterhead.ts`.
+ *
+ * Auth: browser session (PB token forwarded in `Authorization`).
+ *
+ * Response: `{ id, company, confirmed_letterhead }`. 409 while the
+ * field is missing on the live PB (migration not run).
+ */
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function PUT(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ await getBrowserSessionUser(req);
+ const { id } = await params;
+ const body = (await req.json().catch(() => null)) as { letterhead?: unknown } | null;
+ if (!body || !("letterhead" in body)) {
+ return NextResponse.json({ error: "letterhead is required (CNL, ISHERWOOD or null)" }, { status: 422 });
+ }
+ const result = await setCompanyLetterhead(id, body.letterhead as never);
+ return NextResponse.json(result);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Failed to set the company letterhead";
+ const status = (err as Error & { status?: number }).status || 500;
+ return NextResponse.json({ error: message }, { status });
+ }
+}
diff --git a/src/app/api/v1/quotes/[id]/pdf/base64/route.ts b/src/app/api/v1/quotes/[id]/pdf/base64/route.ts
index a8f6d27..ce62428 100644
--- a/src/app/api/v1/quotes/[id]/pdf/base64/route.ts
+++ b/src/app/api/v1/quotes/[id]/pdf/base64/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { LETTERHEAD_CODES, type LetterheadCode } from "@/lib/letterhead";
+import { confirmedLetterheadOf, defaultQuoteLetterhead } from "@/lib/company-letterhead";
/**
* GET /api/v1/quotes/[id]/pdf/base64
@@ -82,17 +83,24 @@ export async function GET(
}
try {
- // Load the quote (we need its company_letterhead as default).
+ // Load the quote (we need its company_letterhead as default, and
+ // the customer's company for its confirmed letterhead — an
+ // unrendered quote follows the company, see company-letterhead.ts).
const quote = await adminPb.collection("quotes").getOne(quoteId, {
- fields: "id,company_letterhead,pdf_generated_at",
+ expand: "customer.company",
});
+ const hasPdf = !!(quote.pdf_generated_at || "").trim();
const chosenLetterhead =
- letterheadParam ?? quote.company_letterhead ?? "CNL";
+ letterheadParam ??
+ defaultQuoteLetterhead({
+ stored: quote.company_letterhead,
+ hasPdf,
+ confirmed: confirmedLetterheadOf(quote.expand?.customer?.expand?.company),
+ });
// Render only if needed: no PDF yet, regeneration forced, or
// letterhead differs from the existing one (best-effort: we just
// always render — POST is idempotent and cheap).
- const hasPdf = !!(quote.pdf_generated_at || "").trim();
if (!hasPdf || regenerate) {
// Re-use the same code path the user-facing button does. The POST
// handler in `[id]/pdf/route.ts` accepts a `letterhead` body.
@@ -111,7 +119,7 @@ export async function GET(
const text = await postRes.text();
return NextResponse.json(
{ error: `PDF render failed: ${postRes.status} ${text}` },
- { status: postRes.status === 404 ? 404 : 500 },
+ { status: postRes.status === 404 || postRes.status === 409 ? postRes.status : 500 },
);
}
const renderResult = (await postRes.json()) as PdfRenderResult;
diff --git a/src/app/api/v1/quotes/[id]/pdf/route.ts b/src/app/api/v1/quotes/[id]/pdf/route.ts
index 1f2fa8a..42af9f1 100644
--- a/src/app/api/v1/quotes/[id]/pdf/route.ts
+++ b/src/app/api/v1/quotes/[id]/pdf/route.ts
@@ -73,7 +73,7 @@ export async function POST(
) {
const { id: quoteId } = await ctx.params;
- let body: { letterhead?: string };
+ let body: { letterhead?: string; override?: boolean; setCompanyDefault?: boolean };
try {
body = await req.json();
} catch {
@@ -104,9 +104,15 @@ export async function POST(
// stored letterhead when the caller passes something unknown —
// same lenient behaviour as before, so a stale UI that sends
// `letterhead: "CNL"` for an ISHERWOOD quote doesn't 500.
+ //
+ // `override` / `setCompanyDefault` are the company letterhead lock
+ // (`src/lib/company-letterhead.ts`): the process page's triple-click
+ // and its "set the default letterhead" popup.
const outcome = await generateQuotePdf(quoteId, {
letterhead: body.letterhead,
actorEmail,
+ override: body.override === true,
+ setCompanyDefault: body.setCompanyDefault === true,
});
return renderOutcomeToResponse(outcome);
@@ -155,6 +161,21 @@ function renderOutcomeToResponse(outcome: GenerateQuotePdfOutcome) {
};
return NextResponse.json(body, { status: 422 });
}
+ if (outcome.kind === "letterhead_locked") {
+ return NextResponse.json(
+ {
+ error: outcome.message,
+ code: "letterhead_locked",
+ details: {
+ requested: outcome.requested,
+ confirmed: outcome.confirmed,
+ companyId: outcome.companyId,
+ companyName: outcome.companyName,
+ },
+ },
+ { status: 409 },
+ );
+ }
// render_error
return NextResponse.json(
{
diff --git a/src/components/quotes/process-letterhead-buttons.test.tsx b/src/components/quotes/process-letterhead-buttons.test.tsx
new file mode 100644
index 0000000..b2c122f
--- /dev/null
+++ b/src/components/quotes/process-letterhead-buttons.test.tsx
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { ProcessLetterheadButtons } from "./process-letterhead-buttons";
+
+function setup(confirmed: "CNL" | "ISHERWOOD" | null | undefined) {
+ const onGenerate = vi.fn();
+ render(
+ ,
+ );
+ return onGenerate;
+}
+
+describe("ProcessLetterheadButtons", () => {
+ it("works like before when the company has no lock field", () => {
+ const onGenerate = setup(undefined);
+ fireEvent.click(screen.getByTestId("generate-pdf-isherwood"));
+ expect(onGenerate).toHaveBeenCalledWith("ISHERWOOD");
+ expect(screen.queryByTestId("letterhead-lock-hint")).toBeNull();
+ });
+
+ it("the confirmed letterhead works on one click", () => {
+ const onGenerate = setup("CNL");
+ fireEvent.click(screen.getByTestId("generate-pdf-cnl"));
+ expect(onGenerate).toHaveBeenCalledWith("CNL");
+ });
+
+ it("the other letterhead needs a triple-click, and then overrides", () => {
+ const onGenerate = setup("CNL");
+ const locked = screen.getByTestId("generate-pdf-isherwood");
+ expect(locked.getAttribute("data-locked")).toBe("true");
+ fireEvent.click(locked, { detail: 1 });
+ fireEvent.click(locked, { detail: 2 });
+ expect(onGenerate).not.toHaveBeenCalled();
+ fireEvent.click(locked, { detail: 3 });
+ expect(onGenerate).toHaveBeenCalledWith("ISHERWOOD", { override: true });
+ expect(screen.getByTestId("letterhead-lock-hint").textContent).toContain("Harmony Gold uses CNL");
+ });
+
+ it("asks for the company default when it is blank, and saves the choice", () => {
+ const onGenerate = setup(null);
+ fireEvent.click(screen.getByTestId("generate-pdf-cnl"));
+ expect(onGenerate).not.toHaveBeenCalled();
+ expect(screen.getByText("Set the default letterhead for Harmony Gold")).toBeTruthy();
+ fireEvent.click(screen.getByTestId("letterhead-default-isherwood"));
+ expect(onGenerate).toHaveBeenCalledWith("ISHERWOOD", { setCompanyDefault: true });
+ });
+});
diff --git a/src/components/quotes/process-letterhead-buttons.tsx b/src/components/quotes/process-letterhead-buttons.tsx
new file mode 100644
index 0000000..3848344
--- /dev/null
+++ b/src/components/quotes/process-letterhead-buttons.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useState, type MouseEvent } from "react";
+import { FileDown, Lock } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ QUOTE_LETTERHEAD_CODES,
+ QUOTE_LETTERHEAD_LABELS,
+ type QuoteLetterheadCode,
+} from "@/lib/company-letterhead";
+import { cn } from "@/lib/utils";
+
+export interface GenerateLetterheadOptions {
+ override?: boolean;
+ setCompanyDefault?: boolean;
+}
+
+/**
+ * The process page's CNL / ISherwood "Generate Quote PDF" buttons with
+ * the company letterhead lock (`src/lib/company-letterhead.ts`):
+ *
+ * - `confirmed` undefined (no company, or the field isn't on the live
+ * PB yet): both buttons work, as before.
+ * - `confirmed` null: either button opens "Set the default letterhead
+ * for {company}"; the choice is saved on the company and rendered.
+ * - `confirmed` set: that button works; the other is locked and only
+ * a triple-click renders on it (an override for this quote only).
+ */
+export function ProcessLetterheadButtons({
+ companyName,
+ confirmed,
+ disabled,
+ generating,
+ onGenerate,
+}: {
+ companyName: string | null;
+ confirmed: QuoteLetterheadCode | null | undefined;
+ disabled: boolean;
+ generating: QuoteLetterheadCode | null;
+ onGenerate: (code: QuoteLetterheadCode, options?: GenerateLetterheadOptions) => void;
+}) {
+ const [askDefault, setAskDefault] = useState(false);
+
+ const handleClick = (code: QuoteLetterheadCode, e: MouseEvent) => {
+ if (confirmed === null) {
+ setAskDefault(true);
+ return;
+ }
+ if (confirmed && code !== confirmed) {
+ // Locked: the first two clicks of a triple-click do nothing.
+ if (e.detail === 3) onGenerate(code, { override: true });
+ return;
+ }
+ onGenerate(code);
+ };
+
+ const chooseDefault = (code: QuoteLetterheadCode) => {
+ setAskDefault(false);
+ onGenerate(code, { setCompanyDefault: true });
+ };
+
+ return (
+