feat: restructure sales, add PDF quote generation and WebSocket support
This commit is contained in:
@@ -354,7 +354,7 @@ export const opportunityCw = {
|
||||
opportunityId: number,
|
||||
): Promise<Record<string, unknown>[]> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate`,
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate,customFields`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface CWOpportunity {
|
||||
closedDate: string;
|
||||
closedBy: CWMemberReference;
|
||||
totalSalesTax: number;
|
||||
probability: CWReference;
|
||||
shipToCompany: CWCompanyReference;
|
||||
shipToContact: CWContactReference;
|
||||
shipToSite: CWSiteReference;
|
||||
|
||||
@@ -14,6 +14,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
|
||||
expectedCloseDate: opportunity.expectedCloseDate,
|
||||
closedDate: opportunity.closedDate,
|
||||
closedFlag: opportunity.closedFlag,
|
||||
probability: Number(opportunity.probability?.name) || 0,
|
||||
type: opportunity.type
|
||||
? { id: opportunity.type.id, name: opportunity.type.name }
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
import PdfPrinter from "pdfmake/src/Printer";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface QuoteLineItem {
|
||||
qty: number;
|
||||
description: string;
|
||||
unitPrice: number;
|
||||
narrative?: string;
|
||||
}
|
||||
|
||||
export interface CustomerInfo {
|
||||
name: string;
|
||||
company?: string;
|
||||
attention?: string;
|
||||
address: string[];
|
||||
}
|
||||
|
||||
export interface CustomerContact {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface QuoteDetails {
|
||||
quoteNumber: string;
|
||||
date: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TaxConfig {
|
||||
rate: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SalesRepInfo {
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface QuoteMetadata {
|
||||
quoteId?: string;
|
||||
createdById?: string;
|
||||
createdByName?: string;
|
||||
createdByEmail?: string;
|
||||
createdAt?: string;
|
||||
downloadedAt?: string;
|
||||
downloadedById?: string;
|
||||
downloadedByName?: string;
|
||||
downloadedByEmail?: string;
|
||||
}
|
||||
|
||||
export interface QuoteData {
|
||||
customer: CustomerInfo;
|
||||
contact: CustomerContact;
|
||||
quote: QuoteDetails;
|
||||
lineItems: QuoteLineItem[];
|
||||
tax: TaxConfig;
|
||||
salesRep?: SalesRepInfo;
|
||||
quoteNarrative?: string;
|
||||
isPreview?: boolean;
|
||||
showLineItemPricing?: boolean;
|
||||
metadata?: QuoteMetadata;
|
||||
}
|
||||
|
||||
export interface QuoteTheme {
|
||||
brandPrimary: string;
|
||||
brandDark: string;
|
||||
brandLight: string;
|
||||
accent: string;
|
||||
headerBg: string;
|
||||
footerBg: string;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: QuoteTheme = {
|
||||
brandPrimary: "#8B5E0B",
|
||||
brandDark: "#5C3D07",
|
||||
brandLight: "#F5EDE0",
|
||||
accent: "#C67F17",
|
||||
headerBg: "#2D2317",
|
||||
footerBg: "#F5EDE0",
|
||||
};
|
||||
|
||||
const SLATE = "#3A3A3A";
|
||||
const SLATE_MID = "#636363";
|
||||
const SLATE_LIGHT = "#8E8E8E";
|
||||
const WHITE = "#FFFFFF";
|
||||
const ROW_ALT = "#FAF7F2";
|
||||
const DIVIDER = "#D4C5A9";
|
||||
|
||||
const PAGE_H = 792;
|
||||
const PAGE_W = 612;
|
||||
const MARGIN_L = 40;
|
||||
const MARGIN_R = 40;
|
||||
const MARGIN_TOP = 26;
|
||||
const MARGIN_BOTTOM = 65;
|
||||
const CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R;
|
||||
|
||||
const DEFAULT_DISCLAIMER =
|
||||
"Prices valid for 30 days from quote date. Taxes invoiced per jurisdiction regardless of presence on this quote.";
|
||||
|
||||
const COMPANY = {
|
||||
name: "Total Tech Solutions LLC",
|
||||
contactPerson: "Courtney Stevens",
|
||||
address: ["PO Box 331", "Murray, KY 42071"],
|
||||
phone: "(270) 761-8324",
|
||||
email: "courtney.stevens@totaltech.net",
|
||||
licenseInfo: "Licensed in Kentucky & Tennessee · TN License #2173",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_LOGO_PATH = join(process.cwd(), "logo.png");
|
||||
|
||||
const fontDir = join(process.cwd(), "node_modules/pdfmake/build/fonts/Roboto");
|
||||
const fonts = {
|
||||
Roboto: {
|
||||
normal: join(fontDir, "Roboto-Regular.ttf"),
|
||||
bold: join(fontDir, "Roboto-Medium.ttf"),
|
||||
italics: join(fontDir, "Roboto-Italic.ttf"),
|
||||
bolditalics: join(fontDir, "Roboto-MediumItalic.ttf"),
|
||||
},
|
||||
};
|
||||
|
||||
const printer = new PdfPrinter(fonts as never);
|
||||
|
||||
const fmt = (n: number) =>
|
||||
"$" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
|
||||
const hr = (color = DIVIDER, weight = 0.75) => ({
|
||||
canvas: [
|
||||
{
|
||||
type: "line",
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: CONTENT_W,
|
||||
y2: 0,
|
||||
lineWidth: weight,
|
||||
lineColor: color,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function loadLogoDataUrl(logoPath: string): string | null {
|
||||
try {
|
||||
const raw = readFileSync(logoPath);
|
||||
const ext = logoPath.toLowerCase().endsWith(".png") ? "png" : "jpeg";
|
||||
return `data:image/${ext};base64,${raw.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateQuote(
|
||||
data: QuoteData,
|
||||
theme: Partial<QuoteTheme> = {},
|
||||
logoPath = DEFAULT_LOGO_PATH,
|
||||
): Promise<Buffer> {
|
||||
const t: QuoteTheme = { ...DEFAULT_THEME, ...theme };
|
||||
const subTotal = data.lineItems.reduce(
|
||||
(sum, item) => sum + item.qty * item.unitPrice,
|
||||
0,
|
||||
);
|
||||
const taxAmount = subTotal * data.tax.rate;
|
||||
const total = subTotal + taxAmount;
|
||||
const logoDataUrl = loadLogoDataUrl(logoPath);
|
||||
|
||||
const showPricing = data.showLineItemPricing ?? false;
|
||||
|
||||
const tableHeader = [
|
||||
{ text: "Qty", style: "thCell", alignment: "center" },
|
||||
{ text: "Description", style: "thCell" },
|
||||
...(showPricing
|
||||
? [
|
||||
{ text: "Unit Price", style: "thCell", alignment: "right" },
|
||||
{ text: "Total", style: "thCell", alignment: "right" },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const colCount = showPricing ? 4 : 2;
|
||||
|
||||
const tableRows: Record<string, unknown>[][] = [];
|
||||
for (const item of data.lineItems) {
|
||||
// Build the description cell — stack description + narrative so they
|
||||
// are a single cell and pdfmake never splits them across pages.
|
||||
const descriptionCell: Record<string, unknown> = item.narrative
|
||||
? {
|
||||
stack: [
|
||||
{ text: item.description, style: "tdCell" },
|
||||
{
|
||||
text: item.narrative,
|
||||
style: "narrative",
|
||||
margin: [0, 2, 8, 0],
|
||||
},
|
||||
],
|
||||
}
|
||||
: { text: item.description, style: "tdCell" };
|
||||
|
||||
tableRows.push([
|
||||
{ text: String(item.qty), style: "tdCell", alignment: "center" },
|
||||
descriptionCell,
|
||||
...(showPricing
|
||||
? [
|
||||
{
|
||||
text: fmt(item.unitPrice),
|
||||
style: "tdCell",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
},
|
||||
{
|
||||
text: fmt(item.qty * item.unitPrice),
|
||||
style: "tdCell",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
const headerImage = logoDataUrl
|
||||
? { image: logoDataUrl, width: 200 }
|
||||
: {
|
||||
stack: [{ text: COMPANY.name, style: "companyName" }],
|
||||
width: 200,
|
||||
};
|
||||
|
||||
const docDefinition = {
|
||||
pageSize: "LETTER" as const,
|
||||
pageMargins: [MARGIN_L, MARGIN_TOP, MARGIN_R, MARGIN_BOTTOM] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
],
|
||||
|
||||
info: {
|
||||
title: `Quote ${data.quote.quoteNumber}`,
|
||||
author: data.metadata?.createdByName ?? COMPANY.name,
|
||||
subject: data.quote.description,
|
||||
creator: COMPANY.name,
|
||||
producer: COMPANY.name,
|
||||
keywords: [
|
||||
data.metadata?.quoteId ? `quoteId:${data.metadata.quoteId}` : null,
|
||||
data.metadata?.createdById
|
||||
? `createdById:${data.metadata.createdById}`
|
||||
: null,
|
||||
data.metadata?.createdByEmail
|
||||
? `createdByEmail:${data.metadata.createdByEmail}`
|
||||
: null,
|
||||
data.metadata?.createdAt
|
||||
? `createdAt:${data.metadata.createdAt}`
|
||||
: null,
|
||||
data.metadata?.downloadedAt
|
||||
? `downloadedAt:${data.metadata.downloadedAt}`
|
||||
: null,
|
||||
data.metadata?.downloadedById
|
||||
? `downloadedById:${data.metadata.downloadedById}`
|
||||
: null,
|
||||
data.metadata?.downloadedByName
|
||||
? `downloadedByName:${data.metadata.downloadedByName}`
|
||||
: null,
|
||||
data.metadata?.downloadedByEmail
|
||||
? `downloadedByEmail:${data.metadata.downloadedByEmail}`
|
||||
: null,
|
||||
data.isPreview ? "preview:true" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
},
|
||||
|
||||
defaultStyle: {
|
||||
font: "Roboto",
|
||||
fontSize: 9.5,
|
||||
color: SLATE,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
|
||||
styles: {
|
||||
companyName: { fontSize: 18, bold: true, color: t.brandDark },
|
||||
quoteLabel: { fontSize: 24, color: t.accent, bold: true, opacity: 0.12 },
|
||||
sectionTitle: {
|
||||
fontSize: 8.5,
|
||||
bold: true,
|
||||
color: t.brandPrimary,
|
||||
characterSpacing: 1.2,
|
||||
},
|
||||
sectionBody: { fontSize: 9, color: SLATE },
|
||||
sectionMuted: { fontSize: 8.5, color: SLATE_MID },
|
||||
infoLabel: {
|
||||
fontSize: 8,
|
||||
bold: true,
|
||||
color: SLATE_LIGHT,
|
||||
characterSpacing: 0.5,
|
||||
},
|
||||
infoValue: { fontSize: 10, bold: true, color: t.brandDark },
|
||||
contactLabel: { fontSize: 8, bold: true, color: SLATE_LIGHT },
|
||||
contactValue: { fontSize: 9, color: SLATE },
|
||||
thCell: {
|
||||
fontSize: 8.5,
|
||||
bold: true,
|
||||
color: WHITE,
|
||||
characterSpacing: 0.5,
|
||||
},
|
||||
tdCell: { fontSize: 9, color: SLATE },
|
||||
narrative: {
|
||||
fontSize: 8,
|
||||
color: SLATE_MID,
|
||||
italics: true,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
totalsLabel: { fontSize: 9, color: SLATE_MID },
|
||||
totalsValue: { fontSize: 9, color: SLATE, bold: true },
|
||||
totalFinalLabel: { fontSize: 11, bold: true, color: WHITE },
|
||||
totalFinalValue: { fontSize: 12, bold: true, color: t.brandDark },
|
||||
footerText: { fontSize: 7.5, color: SLATE_MID },
|
||||
footerBold: { fontSize: 7.5, color: t.brandPrimary, bold: true },
|
||||
disclaimer: { fontSize: 7, color: SLATE_LIGHT, italics: true },
|
||||
},
|
||||
|
||||
...(data.isPreview
|
||||
? {
|
||||
watermark: {
|
||||
text: "PREVIEW",
|
||||
color: t.brandDark,
|
||||
opacity: 0.15,
|
||||
bold: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
background: () => ({
|
||||
canvas: [
|
||||
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 6, color: t.accent },
|
||||
{ type: "rect", x: 0, y: 6, w: 4, h: 786, color: t.brandLight },
|
||||
],
|
||||
}),
|
||||
|
||||
content: [
|
||||
{
|
||||
margin: [0, 4, 0, 0],
|
||||
columns: [
|
||||
headerImage,
|
||||
{
|
||||
stack: [
|
||||
{ text: COMPANY.name, style: "companyName", alignment: "right" },
|
||||
{
|
||||
text: "QUOTE",
|
||||
style: "quoteLabel",
|
||||
alignment: "right",
|
||||
margin: [0, -4, 0, 0],
|
||||
},
|
||||
],
|
||||
width: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{ ...hr(t.accent, 1.5), margin: [0, 8, 0, 0] },
|
||||
|
||||
{
|
||||
margin: [0, 7, 0, 7],
|
||||
columns: [
|
||||
{
|
||||
width: "auto",
|
||||
stack: [
|
||||
{ text: "QUOTE NUMBER", style: "infoLabel" },
|
||||
{
|
||||
text: data.quote.quoteNumber,
|
||||
style: "infoValue",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "auto",
|
||||
margin: [30, 0, 0, 0],
|
||||
stack: [
|
||||
{ text: "DATE", style: "infoLabel" },
|
||||
{
|
||||
text: data.quote.date,
|
||||
style: "infoValue",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "*",
|
||||
margin: [30, 0, 0, 0],
|
||||
stack: [
|
||||
{ text: "DESCRIPTION", style: "infoLabel" },
|
||||
{
|
||||
text: data.quote.description,
|
||||
style: "infoValue",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{ ...hr(), margin: [0, 0, 0, 10] },
|
||||
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
width: 155,
|
||||
stack: [
|
||||
{ text: "FROM", style: "sectionTitle", margin: [0, 0, 0, 6] },
|
||||
{
|
||||
text: data.salesRep?.name ?? COMPANY.contactPerson,
|
||||
style: "sectionBody",
|
||||
bold: true,
|
||||
},
|
||||
{
|
||||
text: COMPANY.name,
|
||||
style: "sectionMuted",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
...COMPANY.address.map((line) => ({
|
||||
text: line,
|
||||
style: "sectionMuted",
|
||||
})),
|
||||
{
|
||||
text: COMPANY.phone,
|
||||
style: "sectionBody",
|
||||
margin: [0, 4, 0, 0],
|
||||
},
|
||||
{
|
||||
text: data.salesRep?.email ?? COMPANY.email,
|
||||
style: "sectionMuted",
|
||||
margin: [0, 1, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
width: 175,
|
||||
margin: [25, 0, 0, 0],
|
||||
stack: [
|
||||
{
|
||||
text: "PREPARED FOR",
|
||||
style: "sectionTitle",
|
||||
margin: [0, 0, 0, 6],
|
||||
},
|
||||
{ text: data.customer.name, style: "sectionBody", bold: true },
|
||||
...(data.customer.company
|
||||
? [
|
||||
{
|
||||
text: data.customer.company,
|
||||
style: "sectionMuted",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(data.customer.attention
|
||||
? [{ text: data.customer.attention, style: "sectionMuted" }]
|
||||
: []),
|
||||
...data.customer.address.map((line) => ({
|
||||
text: line,
|
||||
style: "sectionMuted",
|
||||
})),
|
||||
],
|
||||
},
|
||||
|
||||
...(data.contact.email || data.contact.phone
|
||||
? [
|
||||
{
|
||||
width: "*" as const,
|
||||
margin: [20, 0, 0, 0] as [number, number, number, number],
|
||||
stack: [
|
||||
{
|
||||
text: "CONTACT",
|
||||
style: "sectionTitle",
|
||||
margin: [0, 0, 0, 6],
|
||||
},
|
||||
...(data.contact.email
|
||||
? [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
text: "Email",
|
||||
style: "contactLabel",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
text: data.contact.email,
|
||||
style: "contactValue",
|
||||
width: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(data.contact.phone
|
||||
? [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
text: "Mobile",
|
||||
style: "contactLabel",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
text: data.contact.phone,
|
||||
style: "contactValue",
|
||||
width: "*",
|
||||
},
|
||||
],
|
||||
margin: [0, 4, 0, 0],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
|
||||
{ ...hr(), margin: [0, 10, 0, 0] },
|
||||
|
||||
...(data.quoteNarrative
|
||||
? [
|
||||
{
|
||||
margin: [0, 8, 0, 6] as [number, number, number, number],
|
||||
table: {
|
||||
widths: [2, "*"],
|
||||
body: [
|
||||
[
|
||||
{
|
||||
text: "",
|
||||
fillColor: t.accent,
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
{
|
||||
text: data.quoteNarrative,
|
||||
fontSize: 9,
|
||||
color: SLATE_MID,
|
||||
italics: true,
|
||||
lineHeight: 1.4,
|
||||
margin: [8, 6, 8, 6],
|
||||
fillColor: ROW_ALT,
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
layout: {
|
||||
hLineWidth: () => 0,
|
||||
vLineWidth: () => 0,
|
||||
paddingLeft: () => 0,
|
||||
paddingRight: () => 0,
|
||||
paddingTop: () => 0,
|
||||
paddingBottom: () => 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
margin: [0, 10, 0, 0],
|
||||
table: {
|
||||
headerRows: 1,
|
||||
dontBreakRows: true,
|
||||
widths: showPricing ? [40, "*", 75, 75] : [40, "*"],
|
||||
body: [tableHeader, ...tableRows],
|
||||
},
|
||||
layout: {
|
||||
fillColor: (rowIndex: number) => {
|
||||
if (rowIndex === 0) return t.headerBg;
|
||||
return rowIndex % 2 === 0 ? ROW_ALT : null;
|
||||
},
|
||||
hLineWidth: (i: number, node: { table: { body: unknown[] } }) => {
|
||||
if (i === 0 || i === 1) return 0;
|
||||
if (i === node.table.body.length) return 1;
|
||||
return 0.5;
|
||||
},
|
||||
vLineWidth: () => 0,
|
||||
hLineColor: (i: number, node: { table: { body: unknown[] } }) =>
|
||||
i === node.table.body.length ? t.headerBg : "#E8E0D0",
|
||||
paddingLeft: (col: number) => (col === 0 ? 6 : 8),
|
||||
paddingRight: () => 8,
|
||||
paddingTop: () => 4,
|
||||
paddingBottom: () => 4,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
unbreakable: true,
|
||||
stack: [
|
||||
{
|
||||
margin: [0, 6, 0, 0],
|
||||
columns: [
|
||||
{ width: "*", text: "" },
|
||||
{
|
||||
width: 250,
|
||||
table: {
|
||||
widths: ["*", 110],
|
||||
body: [
|
||||
[
|
||||
{
|
||||
text: "Subtotal",
|
||||
style: "totalsLabel",
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
{
|
||||
text: fmt(subTotal),
|
||||
style: "totalsValue",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: data.tax.label,
|
||||
style: "totalsLabel",
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
{
|
||||
text: fmt(taxAmount),
|
||||
style: "totalsValue",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: "TOTAL",
|
||||
style: "totalFinalLabel",
|
||||
fillColor: t.headerBg,
|
||||
margin: [10, 8, 6, 8],
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
{
|
||||
text: fmt(total),
|
||||
style: "totalFinalValue",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
fillColor: t.brandLight,
|
||||
margin: [6, 7, 8, 7],
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
layout: {
|
||||
hLineWidth: (i: number) => (i >= 1 && i <= 2 ? 0.5 : 0),
|
||||
vLineWidth: () => 0,
|
||||
hLineColor: () => "#E0D6C6",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
margin: [0, 40, 0, 0],
|
||||
columns: [
|
||||
{
|
||||
width: "50%",
|
||||
stack: [
|
||||
{
|
||||
canvas: [
|
||||
{
|
||||
type: "line",
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 220,
|
||||
y2: 0,
|
||||
lineWidth: 0.75,
|
||||
lineColor: "#999",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Authorized Signature",
|
||||
fontSize: 7,
|
||||
color: "#888",
|
||||
margin: [0, 3, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "50%",
|
||||
stack: [
|
||||
{
|
||||
canvas: [
|
||||
{
|
||||
type: "line",
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 160,
|
||||
y2: 0,
|
||||
lineWidth: 0.75,
|
||||
lineColor: "#999",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Date",
|
||||
fontSize: 7,
|
||||
color: "#888",
|
||||
margin: [0, 3, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
footer: (currentPage: number, pageCount: number) => ({
|
||||
margin: [0, 0, 0, 0],
|
||||
stack: [
|
||||
{
|
||||
canvas: [
|
||||
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 44, color: t.footerBg },
|
||||
],
|
||||
},
|
||||
{
|
||||
margin: [MARGIN_L, -38, MARGIN_R, 0],
|
||||
columns: [
|
||||
{
|
||||
width: "*",
|
||||
stack: [
|
||||
{
|
||||
text: [
|
||||
{ text: COMPANY.name, style: "footerBold" },
|
||||
{
|
||||
text: ` · ${COMPANY.licenseInfo}`,
|
||||
style: "footerText",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "auto",
|
||||
text: `Page ${currentPage} of ${pageCount}`,
|
||||
style: "footerText",
|
||||
alignment: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
margin: [MARGIN_L, 4, MARGIN_R, 0],
|
||||
text: DEFAULT_DISCLAIMER,
|
||||
style: "disclaimer",
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const maybeDoc = printer.createPdfKitDocument(docDefinition as never) as any;
|
||||
const pdfDoc =
|
||||
maybeDoc && typeof maybeDoc.then === "function" ? await maybeDoc : maybeDoc;
|
||||
|
||||
if (!pdfDoc || typeof pdfDoc.on !== "function") {
|
||||
throw new Error("Failed to initialize PDF document stream");
|
||||
}
|
||||
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
pdfDoc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
pdfDoc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
pdfDoc.on("error", reject);
|
||||
if (typeof pdfDoc.end === "function") {
|
||||
pdfDoc.end();
|
||||
} else {
|
||||
reject(new Error("PDF document stream does not support end()"));
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./generateQuote";
|
||||
export * from "./injectPdfMetadata";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
|
||||
export interface DownloadMetadata {
|
||||
downloadedAt: string;
|
||||
downloadedById: string;
|
||||
downloadedByName?: string;
|
||||
downloadedByEmail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects download-time metadata into an existing PDF's document properties.
|
||||
*
|
||||
* Appends download-specific key:value pairs to the PDF's Keywords field
|
||||
* (matching the semicolon-delimited format used at commit time) and updates
|
||||
* the ModificationDate.
|
||||
*
|
||||
* Returns the modified PDF as a `Uint8Array`.
|
||||
*/
|
||||
export async function injectPdfMetadata(
|
||||
pdfBytes: Buffer | Uint8Array,
|
||||
metadata: DownloadMetadata,
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// Build new keyword entries in the same format used by generateQuote
|
||||
const newKeywordPairs = [
|
||||
`downloadedAt:${metadata.downloadedAt}`,
|
||||
`downloadedById:${metadata.downloadedById}`,
|
||||
metadata.downloadedByName
|
||||
? `downloadedByName:${metadata.downloadedByName}`
|
||||
: null,
|
||||
metadata.downloadedByEmail
|
||||
? `downloadedByEmail:${metadata.downloadedByEmail}`
|
||||
: null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// Append to existing keywords (preserve commit-time metadata)
|
||||
const existingKeywords = pdfDoc.getKeywords() ?? "";
|
||||
const separator = existingKeywords.length > 0 ? "; " : "";
|
||||
pdfDoc.setKeywords([
|
||||
existingKeywords + separator + newKeywordPairs.join("; "),
|
||||
]);
|
||||
|
||||
// Update modification date to download time
|
||||
pdfDoc.setModificationDate(new Date(metadata.downloadedAt));
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
Reference in New Issue
Block a user