Lock quote letterheads to customer companies
Add a per-company quote letterhead lock backed by `companies.confirmed_letterhead`. The quote process flow prompts for a default when unset, permits only the confirmed letterhead with a triple-click per-quote override, and enforces the rule during PDF generation and email sending. Also add company settings controls and API, email-ingest defaults, migration and backfill tooling, and unit coverage.
This commit is contained in:
parent
afdfbce71d
commit
bae4f9e795
21 changed files with 1216 additions and 74 deletions
39
AGENTS.md
39
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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
116
scripts/2026-10-31-backfill-company-letterheads.ts
Normal file
116
scripts/2026-10-31-backfill-company-letterheads.ts
Normal file
|
|
@ -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<Row>({
|
||||
filter: 'pdf_generated_at != ""',
|
||||
expand: "customer",
|
||||
fields: "company_letterhead,expand.customer.company",
|
||||
});
|
||||
|
||||
const counts = new Map<string, Record<string, number>>();
|
||||
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<string, number> }[] = [];
|
||||
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);
|
||||
});
|
||||
137
scripts/2026-10-31-clean-deliveries-board.ts
Normal file
137
scripts/2026-10-31-clean-deliveries-board.ts
Normal file
|
|
@ -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<string, string>): 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<Line>({
|
||||
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<string, { lines: number; invoices: Set<string> }>();
|
||||
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<string>() };
|
||||
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);
|
||||
});
|
||||
60
scripts/migrations/2026-10-31-add-company-letterhead.ts
Normal file
60
scripts/migrations/2026-10-31-add-company-letterhead.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{letterhead !== undefined && (
|
||||
<Card data-testid="company-letterhead-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Quote letterhead</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{QUOTE_LETTERHEAD_CODES.map((code) => (
|
||||
<Button
|
||||
key={code}
|
||||
size="sm"
|
||||
variant={letterhead === code ? "default" : "outline"}
|
||||
disabled={savingLetterhead || letterhead === code}
|
||||
onClick={() => saveLetterhead(code)}
|
||||
aria-pressed={letterhead === code}
|
||||
>
|
||||
{QUOTE_LETTERHEAD_LABELS[code]}
|
||||
</Button>
|
||||
))}
|
||||
{letterhead && (
|
||||
<Button size="sm" variant="ghost" disabled={savingLetterhead} onClick={() => saveLetterhead(null)}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{letterhead
|
||||
? `Quotes for this company are locked to ${QUOTE_LETTERHEAD_LABELS[letterhead]}.`
|
||||
: "Not confirmed yet — the next quote's PDF button will ask."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Created</CardTitle>
|
||||
|
|
|
|||
|
|
@ -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)`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// Green (CNL) + blue (ISherwood) letterhead chips.
|
||||
// Light-mode palettes; dark-mode flips to a deeper
|
||||
// tint + lighter foreground so the chip still reads
|
||||
// against the dark card. Buttons stay disabled while
|
||||
// any item is unpriced or supplier-less.
|
||||
className="bg-success/15 border-success/40 text-success hover:bg-success/25 dark:bg-success/15 dark:border-success/60 dark:text-success dark:hover:bg-success/25 disabled:opacity-40 disabled:bg-success/10"
|
||||
disabled={!itemsReady || generatingPdf !== null}
|
||||
onClick={() => generatePdf("CNL")}
|
||||
data-testid="generate-pdf-cnl"
|
||||
>
|
||||
<FileDown className="mr-1 h-4 w-4" />
|
||||
{generatingPdf === "CNL" ? "Generating…" : "CNL"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-info/15 border-info/40 text-info hover:bg-info/25 dark:bg-info/15 dark:border-info/60 dark:text-info dark:hover:bg-info/25 disabled:opacity-40 disabled:bg-info/10"
|
||||
disabled={!itemsReady || generatingPdf !== null}
|
||||
onClick={() => generatePdf("ISHERWOOD")}
|
||||
data-testid="generate-pdf-isherwood"
|
||||
>
|
||||
<FileDown className="mr-1 h-4 w-4" />
|
||||
{generatingPdf === "ISHERWOOD" ? "Generating…" : "ISherwood"}
|
||||
</Button>
|
||||
</div>
|
||||
<ProcessLetterheadButtons
|
||||
companyName={letterheadCompany?.company || null}
|
||||
confirmed={companyLetterhead}
|
||||
disabled={!itemsReady}
|
||||
generating={
|
||||
generatingPdf === "CNL" || generatingPdf === "ISHERWOOD" ? generatingPdf : null
|
||||
}
|
||||
onGenerate={(code, options) => generatePdf(code, options)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
38
src/app/api/v1/companies/[id]/letterhead/route.ts
Normal file
38
src/app/api/v1/companies/[id]/letterhead/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
53
src/components/quotes/process-letterhead-buttons.test.tsx
Normal file
53
src/components/quotes/process-letterhead-buttons.test.tsx
Normal file
|
|
@ -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(
|
||||
<ProcessLetterheadButtons
|
||||
companyName="Harmony Gold"
|
||||
confirmed={confirmed}
|
||||
disabled={false}
|
||||
generating={null}
|
||||
onGenerate={onGenerate}
|
||||
/>,
|
||||
);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
151
src/components/quotes/process-letterhead-buttons.tsx
Normal file
151
src/components/quotes/process-letterhead-buttons.tsx
Normal file
|
|
@ -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<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex gap-2">
|
||||
{QUOTE_LETTERHEAD_CODES.map((code) => {
|
||||
const locked = !!confirmed && code !== confirmed;
|
||||
return (
|
||||
<Button
|
||||
key={code}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// Green (CNL) + blue (ISherwood) letterhead chips.
|
||||
// Light-mode palettes; dark-mode flips to a deeper
|
||||
// tint + lighter foreground so the chip still reads
|
||||
// against the dark card. Buttons stay disabled while
|
||||
// any item is unpriced or supplier-less.
|
||||
className={cn(
|
||||
code === "CNL"
|
||||
? "bg-success/15 border-success/40 text-success hover:bg-success/25 dark:bg-success/15 dark:border-success/60 dark:text-success dark:hover:bg-success/25 disabled:opacity-40 disabled:bg-success/10"
|
||||
: "bg-info/15 border-info/40 text-info hover:bg-info/25 dark:bg-info/15 dark:border-info/60 dark:text-info dark:hover:bg-info/25 disabled:opacity-40 disabled:bg-info/10",
|
||||
locked && "opacity-40 select-none",
|
||||
)}
|
||||
disabled={disabled || generating !== null}
|
||||
onClick={(e) => handleClick(code, e)}
|
||||
title={
|
||||
locked
|
||||
? `${companyName ?? "This company"} is quoted on ${QUOTE_LETTERHEAD_LABELS[confirmed]}. Triple-click to use ${QUOTE_LETTERHEAD_LABELS[code]} for this quote only.`
|
||||
: undefined
|
||||
}
|
||||
aria-label={locked ? `${QUOTE_LETTERHEAD_LABELS[code]} (locked, triple-click to override)` : undefined}
|
||||
data-testid={`generate-pdf-${code.toLowerCase()}`}
|
||||
data-locked={locked ? "true" : undefined}
|
||||
>
|
||||
{locked ? <Lock className="mr-1 h-4 w-4" /> : <FileDown className="mr-1 h-4 w-4" />}
|
||||
{generating === code ? "Generating…" : QUOTE_LETTERHEAD_LABELS[code]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{confirmed && (
|
||||
<p className="text-[11px] text-muted-foreground" data-testid="letterhead-lock-hint">
|
||||
{companyName ?? "This company"} uses {QUOTE_LETTERHEAD_LABELS[confirmed]} · triple-click the other to override
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Dialog open={askDefault} onOpenChange={setAskDefault}>
|
||||
<DialogContent className="sm:max-w-md" data-testid="letterhead-default-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set the default letterhead for {companyName ?? "this company"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Every quote for this company will use it. Make sure it's the right one: after
|
||||
this, only that letterhead's button works for this company. You can change it
|
||||
later on the company page.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3 py-2">
|
||||
{QUOTE_LETTERHEAD_CODES.map((code) => (
|
||||
<Button
|
||||
key={code}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-14 text-base font-semibold",
|
||||
code === "CNL"
|
||||
? "bg-success/15 border-success/40 text-success hover:bg-success/25"
|
||||
: "bg-info/15 border-info/40 text-info hover:bg-info/25",
|
||||
)}
|
||||
onClick={() => chooseDefault(code)}
|
||||
data-testid={`letterhead-default-${code.toLowerCase()}`}
|
||||
>
|
||||
{QUOTE_LETTERHEAD_LABELS[code]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setAskDefault(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/lib/company-letterhead.test.ts
Normal file
48
src/lib/company-letterhead.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
checkLetterheadChoice,
|
||||
confirmedLetterheadOf,
|
||||
defaultQuoteLetterhead,
|
||||
} from "./company-letterhead";
|
||||
|
||||
describe("confirmedLetterheadOf", () => {
|
||||
it("is undefined when the field is not on the record (migration not run)", () => {
|
||||
expect(confirmedLetterheadOf({})).toBeUndefined();
|
||||
expect(confirmedLetterheadOf(null)).toBeUndefined();
|
||||
});
|
||||
it("is null when blank or unknown, the code when set", () => {
|
||||
expect(confirmedLetterheadOf({ confirmed_letterhead: "" })).toBeNull();
|
||||
expect(confirmedLetterheadOf({ confirmed_letterhead: "WESTSCOTT GROUP OF COMPANIES" })).toBeNull();
|
||||
expect(confirmedLetterheadOf({ confirmed_letterhead: "ISHERWOOD" })).toBe("ISHERWOOD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkLetterheadChoice", () => {
|
||||
it("allows anything when the company has no confirmed letterhead", () => {
|
||||
expect(checkLetterheadChoice({ requested: "ISHERWOOD", confirmed: null })).toEqual({ ok: true });
|
||||
expect(checkLetterheadChoice({ requested: "ISHERWOOD", confirmed: undefined })).toEqual({ ok: true });
|
||||
});
|
||||
it("allows the match, refuses the other, allows it on override", () => {
|
||||
expect(checkLetterheadChoice({ requested: "CNL", confirmed: "CNL" })).toEqual({ ok: true });
|
||||
expect(checkLetterheadChoice({ requested: "ISHERWOOD", confirmed: "CNL" })).toEqual({ ok: false, confirmed: "CNL" });
|
||||
expect(checkLetterheadChoice({ requested: "ISHERWOOD", confirmed: "CNL", override: true })).toEqual({ ok: true });
|
||||
});
|
||||
it("lets a quote already rendered on the other letterhead keep it", () => {
|
||||
expect(
|
||||
checkLetterheadChoice({ requested: "ISHERWOOD", confirmed: "CNL", renderedLetterhead: "ISHERWOOD" }),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultQuoteLetterhead", () => {
|
||||
it("keeps a rendered quote's own letterhead", () => {
|
||||
expect(defaultQuoteLetterhead({ stored: "ISHERWOOD", hasPdf: true, confirmed: "CNL" })).toBe("ISHERWOOD");
|
||||
});
|
||||
it("takes the company's for an unrendered quote", () => {
|
||||
expect(defaultQuoteLetterhead({ stored: "CNL", hasPdf: false, confirmed: "ISHERWOOD" })).toBe("ISHERWOOD");
|
||||
});
|
||||
it("falls back to the stored value, then CNL", () => {
|
||||
expect(defaultQuoteLetterhead({ stored: "ISHERWOOD", hasPdf: false, confirmed: null })).toBe("ISHERWOOD");
|
||||
expect(defaultQuoteLetterhead({ stored: "", hasPdf: false, confirmed: undefined })).toBe("CNL");
|
||||
});
|
||||
});
|
||||
82
src/lib/company-letterhead.ts
Normal file
82
src/lib/company-letterhead.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Company letterhead lock — client-safe rules.
|
||||
*
|
||||
* Every company is quoted on ONE letterhead (CNL or ISherwood). The
|
||||
* company's choice lives on `companies.confirmed_letterhead` (a select,
|
||||
* blank until someone confirms it). Once it is set, a quote for that
|
||||
* company can only be rendered on that letterhead unless the operator
|
||||
* deliberately overrides (the triple-click on the process page).
|
||||
*
|
||||
* The company is always the CUSTOMER's company (`customer.company`),
|
||||
* never `quotes.company` — that copy is not kept up to date when a
|
||||
* quote's customer changes (see QUOTE_LIST_EXPAND in quotes-service).
|
||||
*
|
||||
* Shared by `quote-pdf-service.ts` (the server check every render goes
|
||||
* through) and the process page (which buttons are live).
|
||||
*/
|
||||
|
||||
export type QuoteLetterheadCode = "CNL" | "ISHERWOOD";
|
||||
|
||||
export const QUOTE_LETTERHEAD_CODES: readonly QuoteLetterheadCode[] = ["CNL", "ISHERWOOD"] as const;
|
||||
|
||||
export const QUOTE_LETTERHEAD_LABELS: Record<QuoteLetterheadCode, string> = {
|
||||
CNL: "CNL",
|
||||
ISHERWOOD: "ISherwood",
|
||||
};
|
||||
|
||||
export function isQuoteLetterheadCode(value: unknown): value is QuoteLetterheadCode {
|
||||
return typeof value === "string" && (QUOTE_LETTERHEAD_CODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's confirmed letterhead, or null when it is blank.
|
||||
* `undefined` when the record has no such field at all — the live PB
|
||||
* has not had `npm run pb:migrate:company-letterhead` yet, so the lock
|
||||
* is off and everything behaves as it did before.
|
||||
*/
|
||||
export function confirmedLetterheadOf(
|
||||
company: { confirmed_letterhead?: unknown } | null | undefined,
|
||||
): QuoteLetterheadCode | null | undefined {
|
||||
if (!company || !("confirmed_letterhead" in company)) return undefined;
|
||||
return isQuoteLetterheadCode(company.confirmed_letterhead) ? company.confirmed_letterhead : null;
|
||||
}
|
||||
|
||||
export type LetterheadCheck =
|
||||
| { ok: true }
|
||||
| { ok: false; confirmed: QuoteLetterheadCode };
|
||||
|
||||
/**
|
||||
* May this quote be rendered on `requested`?
|
||||
*
|
||||
* Allowed when the company has no confirmed letterhead, when it matches,
|
||||
* when the operator overrides, or when the quote already has a PDF on
|
||||
* `requested` (an earlier override on this quote stays put, so a later
|
||||
* re-render — e.g. the send's staleness regen — doesn't need it again).
|
||||
*/
|
||||
export function checkLetterheadChoice(args: {
|
||||
requested: string;
|
||||
confirmed: QuoteLetterheadCode | null | undefined;
|
||||
/** The quote's stored letterhead, only when the quote already has a PDF. */
|
||||
renderedLetterhead?: string | null;
|
||||
override?: boolean;
|
||||
}): LetterheadCheck {
|
||||
const { requested, confirmed, renderedLetterhead, override } = args;
|
||||
if (!confirmed || requested === confirmed || override) return { ok: true };
|
||||
if (renderedLetterhead && renderedLetterhead === requested) return { ok: true };
|
||||
return { ok: false, confirmed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The letterhead to use when the caller didn't pick one: the quote's
|
||||
* own once it has a PDF (that is what was sent / approved), otherwise
|
||||
* the company's confirmed one, otherwise the quote's stored default.
|
||||
*/
|
||||
export function defaultQuoteLetterhead(args: {
|
||||
stored: string | null | undefined;
|
||||
hasPdf: boolean;
|
||||
confirmed: QuoteLetterheadCode | null | undefined;
|
||||
}): QuoteLetterheadCode {
|
||||
const stored = isQuoteLetterheadCode(args.stored) ? args.stored : null;
|
||||
if (args.hasPdf && stored) return stored;
|
||||
return args.confirmed || stored || "CNL";
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
/** Invoices dated before this never enter the pool (they went out on the old flow). */
|
||||
export const DELIVERY_POOL_START = "2026-10-30";
|
||||
export const DELIVERY_POOL_START = "2026-10-28";
|
||||
|
||||
export type DeliveryPoolLine = {
|
||||
id: string;
|
||||
|
|
|
|||
46
src/lib/server/company-letterhead-service.ts
Normal file
46
src/lib/server/company-letterhead-service.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import {
|
||||
confirmedLetterheadOf,
|
||||
isQuoteLetterheadCode,
|
||||
type QuoteLetterheadCode,
|
||||
} from "@/lib/company-letterhead";
|
||||
|
||||
/**
|
||||
* Company letterhead lock — the write side (the company page's
|
||||
* "Quote letterhead" card). The rules live in
|
||||
* `src/lib/company-letterhead.ts`; the quote PDF service enforces them.
|
||||
*/
|
||||
|
||||
export const COMPANY_LETTERHEAD_NOT_SET_UP =
|
||||
"Company letterheads are not set up yet: run `npm run pb:migrate:company-letterhead` against the live PocketBase.";
|
||||
|
||||
/**
|
||||
* Set (or clear, with null) a company's confirmed quote letterhead.
|
||||
* Refuses while the field is missing on the live PB, because PB drops
|
||||
* unknown fields silently and the save would look like it worked.
|
||||
*/
|
||||
export async function setCompanyLetterhead(
|
||||
companyId: string,
|
||||
letterhead: QuoteLetterheadCode | null,
|
||||
): Promise<{ id: string; company: string; confirmed_letterhead: QuoteLetterheadCode | null }> {
|
||||
if (letterhead !== null && !isQuoteLetterheadCode(letterhead)) {
|
||||
throw Object.assign(new Error("letterhead must be CNL, ISHERWOOD or null"), { status: 422 });
|
||||
}
|
||||
const pb = await getAdminPocketBase();
|
||||
const current = await pb
|
||||
.collection("companies")
|
||||
.getOne<{ id: string; company: string; confirmed_letterhead?: unknown }>(companyId);
|
||||
if (confirmedLetterheadOf(current) === undefined) {
|
||||
throw Object.assign(new Error(COMPANY_LETTERHEAD_NOT_SET_UP), { status: 409 });
|
||||
}
|
||||
const updated = await pb
|
||||
.collection("companies")
|
||||
.update<{ id: string; company: string; confirmed_letterhead?: unknown }>(companyId, {
|
||||
confirmed_letterhead: letterhead ?? "",
|
||||
});
|
||||
return {
|
||||
id: updated.id,
|
||||
company: updated.company,
|
||||
confirmed_letterhead: confirmedLetterheadOf(updated) ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -174,6 +174,14 @@ const { pbMock, clearStore } = vi.hoisted(() => {
|
|||
...hit,
|
||||
expand: { [nested]: nestedHit ? { ...nestedHit, id: nestedId } : null },
|
||||
};
|
||||
// Real PB nests `a.b` under `expand.a.expand.b`.
|
||||
const parent = expand[target] as Record<string, unknown> | null | undefined;
|
||||
if (parent && nestedHit) {
|
||||
parent.expand = {
|
||||
...((parent.expand as Record<string, unknown>) ?? {}),
|
||||
[nested]: { ...nestedHit, id: nestedId },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
expand[rel] = { ...hit, expand: { [nested]: null } };
|
||||
}
|
||||
|
|
@ -1240,3 +1248,141 @@ describe("generateQuotePdf auto-claim", () => {
|
|||
expect(after.claimed_by).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// generateQuotePdf — company letterhead lock (src/lib/company-letterhead.ts)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("generateQuotePdf — company letterhead lock", () => {
|
||||
async function seedLocked(opts: {
|
||||
confirmed?: string;
|
||||
stored?: string;
|
||||
rendered?: boolean;
|
||||
customerName?: string;
|
||||
customerEmail?: string;
|
||||
}) {
|
||||
const company = await pbMock.collection("companies").create({
|
||||
company: "Harmony Gold",
|
||||
...(opts.confirmed !== undefined ? { confirmed_letterhead: opts.confirmed } : {}),
|
||||
});
|
||||
const customer = await pbMock.collection("customers").create({
|
||||
customer: opts.customerName ?? "Jan Buyer",
|
||||
email: opts.customerEmail ?? "jan@harmony.co.za",
|
||||
company: company.id,
|
||||
});
|
||||
const quote = await pbMock.collection("quotes").create({
|
||||
quote_number: "Q-2026-10-777",
|
||||
customer: customer.id,
|
||||
status: "processed",
|
||||
company_letterhead: opts.stored ?? "CNL",
|
||||
pdf_generated_at: opts.rendered ? "2026-10-30T10:00:00Z" : "",
|
||||
});
|
||||
await pbMock.collection("quote_items").create({
|
||||
quote: quote.id,
|
||||
description: "M16 hex bolt",
|
||||
quantity: 1,
|
||||
cost_price: 2.39,
|
||||
selling_price: 3.11,
|
||||
markup: 30,
|
||||
supplier: "sup-acme",
|
||||
is_processed: true,
|
||||
moved_to_quote: null,
|
||||
});
|
||||
return { company, customer, quote };
|
||||
}
|
||||
|
||||
const pdfCount = async (quoteId: string) =>
|
||||
(
|
||||
await pbMock
|
||||
.collection("quote_attachments")
|
||||
.getFullList({ filter: `quote = "${quoteId}" && kind = "pdf"` })
|
||||
).length;
|
||||
|
||||
it("refuses the other letterhead without an override and writes nothing", async () => {
|
||||
const { quote, company } = await seedLocked({ confirmed: "CNL" });
|
||||
const outcome = await generateQuotePdf(quote.id, { letterhead: "ISHERWOOD" });
|
||||
expect(outcome.kind).toBe("letterhead_locked");
|
||||
if (outcome.kind !== "letterhead_locked") throw new Error("expected locked");
|
||||
expect(outcome.confirmed).toBe("CNL");
|
||||
expect(outcome.companyId).toBe(company.id);
|
||||
expect(outcome.message).toContain("Harmony Gold");
|
||||
expect(await pdfCount(quote.id)).toBe(0);
|
||||
const after = await pbMock.collection("quotes").getOne<{ company_letterhead: string }>(quote.id);
|
||||
expect(after.company_letterhead).toBe("CNL");
|
||||
});
|
||||
|
||||
it("renders the company's letterhead normally", async () => {
|
||||
const { quote } = await seedLocked({ confirmed: "ISHERWOOD", stored: "CNL" });
|
||||
const outcome = await generateQuotePdf(quote.id, { letterhead: "ISHERWOOD" });
|
||||
expect(outcome.kind).toBe("ok");
|
||||
});
|
||||
|
||||
it("renders the other letterhead on override, and the override sticks for a later regen", async () => {
|
||||
const { quote } = await seedLocked({ confirmed: "CNL" });
|
||||
const first = await generateQuotePdf(quote.id, { letterhead: "ISHERWOOD", override: true });
|
||||
expect(first.kind).toBe("ok");
|
||||
if (first.kind !== "ok") throw new Error("expected ok");
|
||||
expect(first.companyLetterhead).toBe("ISHERWOOD");
|
||||
|
||||
// The send's staleness regen passes no letterhead.
|
||||
const regen = await generateQuotePdf(quote.id);
|
||||
expect(regen.kind).toBe("ok");
|
||||
if (regen.kind !== "ok") throw new Error("expected ok");
|
||||
expect(regen.companyLetterhead).toBe("ISHERWOOD");
|
||||
});
|
||||
|
||||
it("an unrendered quote takes the company's letterhead when none is passed, and stores it", async () => {
|
||||
const { quote } = await seedLocked({ confirmed: "ISHERWOOD", stored: "CNL" });
|
||||
const outcome = await generateQuotePdf(quote.id);
|
||||
expect(outcome.kind).toBe("ok");
|
||||
if (outcome.kind !== "ok") throw new Error("expected ok");
|
||||
expect(outcome.companyLetterhead).toBe("ISHERWOOD");
|
||||
const after = await pbMock.collection("quotes").getOne<{ company_letterhead: string }>(quote.id);
|
||||
expect(after.company_letterhead).toBe("ISHERWOOD");
|
||||
});
|
||||
|
||||
it("saves the company default from the popup, then renders on it", async () => {
|
||||
const { quote, company } = await seedLocked({ confirmed: "" });
|
||||
const outcome = await generateQuotePdf(quote.id, {
|
||||
letterhead: "ISHERWOOD",
|
||||
setCompanyDefault: true,
|
||||
});
|
||||
expect(outcome.kind).toBe("ok");
|
||||
const after = await pbMock
|
||||
.collection("companies")
|
||||
.getOne<{ confirmed_letterhead: string }>(company.id);
|
||||
expect(after.confirmed_letterhead).toBe("ISHERWOOD");
|
||||
});
|
||||
|
||||
it("never overwrites an already-confirmed company via setCompanyDefault", async () => {
|
||||
const { quote, company } = await seedLocked({ confirmed: "CNL" });
|
||||
const outcome = await generateQuotePdf(quote.id, {
|
||||
letterhead: "ISHERWOOD",
|
||||
setCompanyDefault: true,
|
||||
});
|
||||
expect(outcome.kind).toBe("letterhead_locked");
|
||||
const after = await pbMock
|
||||
.collection("companies")
|
||||
.getOne<{ confirmed_letterhead: string }>(company.id);
|
||||
expect(after.confirmed_letterhead).toBe("CNL");
|
||||
});
|
||||
|
||||
it("does not confirm a placeholder customer's company", async () => {
|
||||
const { quote, company } = await seedLocked({ confirmed: "", customerEmail: "" });
|
||||
const outcome = await generateQuotePdf(quote.id, {
|
||||
letterhead: "CNL",
|
||||
setCompanyDefault: true,
|
||||
});
|
||||
expect(outcome.kind).toBe("readiness_failure");
|
||||
const after = await pbMock
|
||||
.collection("companies")
|
||||
.getOne<{ confirmed_letterhead: string }>(company.id);
|
||||
expect(after.confirmed_letterhead).toBe("");
|
||||
});
|
||||
|
||||
it("has no lock while the field is missing on the live PB", async () => {
|
||||
const { quote } = await seedLocked({});
|
||||
const outcome = await generateQuotePdf(quote.id, { letterhead: "ISHERWOOD" });
|
||||
expect(outcome.kind).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ import {
|
|||
loadLetterheadLogoDataUri,
|
||||
type LetterheadCode,
|
||||
} from "@/lib/letterhead";
|
||||
import {
|
||||
checkLetterheadChoice,
|
||||
confirmedLetterheadOf,
|
||||
defaultQuoteLetterhead,
|
||||
isQuoteLetterheadCode,
|
||||
QUOTE_LETTERHEAD_LABELS,
|
||||
type QuoteLetterheadCode,
|
||||
} from "@/lib/company-letterhead";
|
||||
import { renderQuotePdf } from "@/lib/pdf/render";
|
||||
import {
|
||||
PLACEHOLDER_CUSTOMER_IDS,
|
||||
|
|
@ -171,6 +179,19 @@ export type GenerateQuotePdfOutcome =
|
|||
issues?: QuotePdfReadinessIssue[];
|
||||
};
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* The customer's company is confirmed on another letterhead and
|
||||
* the caller did not override (see `company-letterhead.ts`).
|
||||
* Nothing was rendered or written.
|
||||
*/
|
||||
kind: "letterhead_locked";
|
||||
message: string;
|
||||
requested: string;
|
||||
confirmed: QuoteLetterheadCode;
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
}
|
||||
| {
|
||||
kind: "render_error";
|
||||
message: string;
|
||||
|
|
@ -206,7 +227,17 @@ export type GenerateQuotePdfOutcome =
|
|||
*/
|
||||
export async function generateQuotePdf(
|
||||
quoteId: string,
|
||||
options?: { letterhead?: string; actorEmail?: string },
|
||||
options?: {
|
||||
letterhead?: string;
|
||||
actorEmail?: string;
|
||||
/** Render on a letterhead other than the company's confirmed one (the triple-click). */
|
||||
override?: boolean;
|
||||
/**
|
||||
* The company has no confirmed letterhead yet: confirm `letterhead`
|
||||
* as its default before rendering (the process page's popup).
|
||||
*/
|
||||
setCompanyDefault?: boolean;
|
||||
},
|
||||
): Promise<GenerateQuotePdfOutcome> {
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
|
|
@ -240,6 +271,31 @@ export async function generateQuotePdf(
|
|||
};
|
||||
}
|
||||
|
||||
// ── 1b. Customer readiness gate ──────────────────────────
|
||||
// (Runs before the letterhead lock so a placeholder customer
|
||||
// never gets its company confirmed on a letterhead.)
|
||||
// Block on placeholder customer. The office has to pick a
|
||||
// real one on the process page before they can generate a
|
||||
// PDF — sending a quote addressed to "Unknown Sender" is a
|
||||
// data leak waiting to happen.
|
||||
if (
|
||||
PLACEHOLDER_CUSTOMER_IDS.has(customer.id) ||
|
||||
PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim()) ||
|
||||
!customer.email
|
||||
) {
|
||||
return {
|
||||
kind: "readiness_failure",
|
||||
code: "placeholder_customer",
|
||||
message:
|
||||
"This quote is still pointed at a placeholder / unnamed customer. " +
|
||||
"Pick the real customer on the process page before generating a PDF.",
|
||||
details: {
|
||||
customerId: customer.id,
|
||||
customerName: customer.customer || "(blank)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 2. Letterhead resolution ─────────────────────────────
|
||||
// Default to the quote's stored letterhead; let an explicit
|
||||
// caller override win (the route does this from the request
|
||||
|
|
@ -249,12 +305,55 @@ export async function generateQuotePdf(
|
|||
(LETTERHEAD_CODES as readonly string[]).includes(options.letterhead)
|
||||
? (options.letterhead as LetterheadCode)
|
||||
: null;
|
||||
const requestedCode = operatorLetterheadOverride ?? quote.company_letterhead;
|
||||
// ── 2a. Company letterhead lock ──────────────────────────
|
||||
// The customer's company may be confirmed on one letterhead
|
||||
// (`companies.confirmed_letterhead`). An explicit choice that
|
||||
// differs is refused unless the operator overrode; with no
|
||||
// explicit choice, an unrendered quote takes the company's.
|
||||
const company = customer.expand?.company as
|
||||
| { id: string; company?: string; confirmed_letterhead?: unknown }
|
||||
| undefined;
|
||||
let confirmed = confirmedLetterheadOf(company);
|
||||
const hasPdf = !!(quote.pdf_generated_at || "").trim();
|
||||
if (company && confirmed === null && options?.setCompanyDefault && operatorLetterheadOverride) {
|
||||
if (!isQuoteLetterheadCode(operatorLetterheadOverride)) {
|
||||
return {
|
||||
kind: "render_error",
|
||||
message: `${operatorLetterheadOverride} cannot be a company's quote letterhead`,
|
||||
};
|
||||
}
|
||||
await pb.collection("companies").update(company.id, {
|
||||
confirmed_letterhead: operatorLetterheadOverride,
|
||||
});
|
||||
confirmed = operatorLetterheadOverride;
|
||||
}
|
||||
const requestedCode =
|
||||
operatorLetterheadOverride ??
|
||||
defaultQuoteLetterhead({ stored: quote.company_letterhead, hasPdf, confirmed });
|
||||
const letterheadCode = (LETTERHEAD_CODES as readonly string[]).includes(
|
||||
requestedCode || "",
|
||||
)
|
||||
? (requestedCode as LetterheadCode)
|
||||
: ("CNL" as LetterheadCode);
|
||||
const lock = checkLetterheadChoice({
|
||||
requested: letterheadCode,
|
||||
confirmed,
|
||||
renderedLetterhead: hasPdf ? quote.company_letterhead : null,
|
||||
override: options?.override === true,
|
||||
});
|
||||
if (!lock.ok && company) {
|
||||
const companyName = company.company || "This company";
|
||||
return {
|
||||
kind: "letterhead_locked",
|
||||
message:
|
||||
`${companyName} is quoted on the ${QUOTE_LETTERHEAD_LABELS[lock.confirmed]} letterhead. ` +
|
||||
`Use ${QUOTE_LETTERHEAD_LABELS[lock.confirmed]}, or override it on the quote's process page.`,
|
||||
requested: letterheadCode,
|
||||
confirmed: lock.confirmed,
|
||||
companyId: company.id,
|
||||
companyName,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 2b. Auto-claim resolution ────────────────────────────
|
||||
// Resolve `actorEmail` (from the route's `getServerIdentity`) to
|
||||
|
|
@ -294,29 +393,6 @@ export async function generateQuotePdf(
|
|||
}
|
||||
}
|
||||
|
||||
// ── 3. Customer readiness gate ───────────────────────────
|
||||
// Block on placeholder customer. The office has to pick a
|
||||
// real one on the process page before they can generate a
|
||||
// PDF — sending a quote addressed to "Unknown Sender" is a
|
||||
// data leak waiting to happen.
|
||||
if (
|
||||
PLACEHOLDER_CUSTOMER_IDS.has(customer.id) ||
|
||||
PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim()) ||
|
||||
!customer.email
|
||||
) {
|
||||
return {
|
||||
kind: "readiness_failure",
|
||||
code: "placeholder_customer",
|
||||
message:
|
||||
"This quote is still pointed at a placeholder / unnamed customer. " +
|
||||
"Pick the real customer on the process page before generating a PDF.",
|
||||
details: {
|
||||
customerId: customer.id,
|
||||
customerName: customer.customer || "(blank)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 4. Item readiness gate ───────────────────────────────
|
||||
// Active items = priced items that haven't been moved to a
|
||||
// continuation quote. Items with `moved_to_quote != null`
|
||||
|
|
@ -475,9 +551,15 @@ export async function generateQuotePdf(
|
|||
// when the caller passed a *known* letterhead code (not a
|
||||
// typo'd unknown code) so a silent fallback to CNL doesn't
|
||||
// silently rewrite the stored value either.
|
||||
// An unrendered quote that took its company's confirmed letterhead
|
||||
// is persisted too, so the quote page shows what was rendered.
|
||||
const tookCompanyLetterhead =
|
||||
!operatorLetterheadOverride && !!confirmed && letterheadCode === confirmed;
|
||||
const letterheadPersistence = operatorLetterheadOverride
|
||||
? { company_letterhead: operatorLetterheadOverride }
|
||||
: {};
|
||||
: tookCompanyLetterhead && quote.company_letterhead !== letterheadCode
|
||||
? { company_letterhead: letterheadCode }
|
||||
: {};
|
||||
let newStatus = quote.status;
|
||||
let pdfGeneratedAt: string | null = quote.pdf_generated_at || null;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
*/
|
||||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import type { Quote, Customer, Company } from "@/lib/types";
|
||||
import { confirmedLetterheadOf, type QuoteLetterheadCode } from "@/lib/company-letterhead";
|
||||
import {
|
||||
findOrCreateCustomer,
|
||||
findOrCreateCompany,
|
||||
|
|
@ -221,6 +222,10 @@ export async function ingestQuoteFromEmail(
|
|||
// the upload step is allowed to fail (per file) without rolling
|
||||
// back the quote, so this is the operator's "expect files"
|
||||
// hint rather than the source of truth. ─────────────────────
|
||||
// The customer's company may be confirmed on a letterhead
|
||||
// (company-letterhead.ts); start the quote on it instead of CNL.
|
||||
const letterhead = await customerCompanyLetterhead(pb, customer);
|
||||
|
||||
const quote = await pb.collection(QUOTES_COLLECTION).create({
|
||||
quote_number: quoteNumber,
|
||||
customer: customer.id,
|
||||
|
|
@ -232,6 +237,7 @@ export async function ingestQuoteFromEmail(
|
|||
has_attachments: (payload.attachments?.length ?? 0) > 0,
|
||||
email_message_id: effectiveMessageId,
|
||||
...INTAKE_FIELDS,
|
||||
...(letterhead ? { company_letterhead: letterhead } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -243,3 +249,24 @@ export async function ingestQuoteFromEmail(
|
|||
quote_number: quoteNumber,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmed letterhead of the customer's company, or null (no
|
||||
* company, not confirmed, or the lookup failed — the intake default
|
||||
* stands and the PDF service still applies the lock at render time).
|
||||
*/
|
||||
async function customerCompanyLetterhead(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
customer: Customer,
|
||||
): Promise<QuoteLetterheadCode | null> {
|
||||
const companyId = typeof customer.company === "string" ? customer.company : "";
|
||||
if (!companyId) return null;
|
||||
try {
|
||||
const company = await pb
|
||||
.collection(COMPANIES_COLLECTION)
|
||||
.getOne<{ confirmed_letterhead?: unknown }>(companyId);
|
||||
return confirmedLetterheadOf(company) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2240,6 +2240,15 @@ export async function sendQuoteEmail(
|
|||
details: regen.details,
|
||||
});
|
||||
}
|
||||
if (regen.kind === "letterhead_locked") {
|
||||
// The stored letterhead isn't the company's confirmed one and
|
||||
// was never rendered (an override sticks once rendered), so
|
||||
// don't ship the stale PDF on the wrong letterhead.
|
||||
return markPreSendFailure(pb, quote, {
|
||||
code: "items_not_ready",
|
||||
message: "Could not regenerate the PDF — the email was not sent. " + regen.message,
|
||||
});
|
||||
}
|
||||
if (regen.kind === "render_error") {
|
||||
// The render itself crashed (PB outage, render crash).
|
||||
// Same shape — refuse to send, don't ship the stale PDF.
|
||||
|
|
|
|||
|
|
@ -122,6 +122,12 @@ export interface Company extends BaseRecord {
|
|||
* radius filter without re-parsing.
|
||||
*/
|
||||
coordinates?: { lon: number; lat: number };
|
||||
/**
|
||||
* The company's quote letterhead, "" until confirmed. Absent on the
|
||||
* record until `npm run pb:migrate:company-letterhead` has run. See
|
||||
* `src/lib/company-letterhead.ts`.
|
||||
*/
|
||||
confirmed_letterhead?: "" | "CNL" | "ISHERWOOD";
|
||||
}
|
||||
|
||||
export interface CompanyDetails extends BaseRecord {
|
||||
|
|
|
|||
Loading…
Reference in a new issue