839 lines
42 KiB
TypeScript
839 lines
42 KiB
TypeScript
import type { jsPDF } from 'jspdf';
|
|
import type { Quote, QuotePdfAppearance, QuotePdfTextStyle, QuoteService, ShopSettings } from '../types';
|
|
import { billableServices, computeSplitQuoteTotals } from './totals';
|
|
import type { TotalsBreakdown } from './totals';
|
|
import { parseRichText, hasRichText, type RichSegment } from './richtext';
|
|
import { DEFAULT_QUOTE_PDF_APPEARANCE, getProviderLabel } from './settings';
|
|
|
|
// ─── Layout Constants ─────────────────────────────────────────────────────────
|
|
const PAGE_W = 612;
|
|
const PAGE_H = 792;
|
|
const MARGIN = 40;
|
|
const CW = PAGE_W - MARGIN * 2;
|
|
const CONTENT_BOTTOM = PAGE_H - MARGIN; // 752 — bottom of content area (above footer)
|
|
const FOOTER_HEIGHT = 50; // reserved for payment terms, separator + footer text + page number
|
|
|
|
type ColorTuple = [number, number, number];
|
|
const BL: ColorTuple = [18, 18, 18];
|
|
const DG: ColorTuple = [64, 64, 64];
|
|
const MG: ColorTuple = [80, 80, 80];
|
|
const GR: ColorTuple = [22, 163, 74];
|
|
const BU: ColorTuple = [37, 99, 235];
|
|
const OR: ColorTuple = [234, 88, 12];
|
|
|
|
function authorizationText(svc: QuoteService, appearance: QuotePdfAppearance): string {
|
|
const methodLabels: Record<string, string> = {
|
|
in_person: appearance.labels.authorizedInPerson,
|
|
phone: appearance.labels.authorizedByCall,
|
|
email: appearance.labels.authorizedByEmail,
|
|
text: appearance.labels.authorizedByText,
|
|
signature: appearance.labels.authorizedWithSignature,
|
|
online: appearance.labels.authorizedOnline,
|
|
};
|
|
const methodLabel = svc.authorizedMethod ? methodLabels[svc.authorizedMethod] : '';
|
|
if (!methodLabel) return svc.authorizedBy || '';
|
|
return svc.authorizedBy ? `${methodLabel}: ${svc.authorizedBy}` : methodLabel;
|
|
}
|
|
|
|
function quotePdfAppearance(settings: ShopSettings): QuotePdfAppearance {
|
|
const incoming = settings.quotePdfAppearance || DEFAULT_QUOTE_PDF_APPEARANCE;
|
|
return {
|
|
...DEFAULT_QUOTE_PDF_APPEARANCE,
|
|
...incoming,
|
|
sectionHeader: { ...DEFAULT_QUOTE_PDF_APPEARANCE.sectionHeader, ...incoming.sectionHeader },
|
|
serviceTitle: { ...DEFAULT_QUOTE_PDF_APPEARANCE.serviceTitle, ...incoming.serviceTitle },
|
|
serviceDescription: { ...DEFAULT_QUOTE_PDF_APPEARANCE.serviceDescription, ...incoming.serviceDescription },
|
|
recommendation: { ...DEFAULT_QUOTE_PDF_APPEARANCE.recommendation, ...incoming.recommendation },
|
|
priority: { ...DEFAULT_QUOTE_PDF_APPEARANCE.priority, ...incoming.priority },
|
|
authorization: { ...DEFAULT_QUOTE_PDF_APPEARANCE.authorization, ...incoming.authorization },
|
|
totals: { ...DEFAULT_QUOTE_PDF_APPEARANCE.totals, ...incoming.totals },
|
|
footer: { ...DEFAULT_QUOTE_PDF_APPEARANCE.footer, ...incoming.footer },
|
|
colors: { ...DEFAULT_QUOTE_PDF_APPEARANCE.colors, ...incoming.colors },
|
|
labels: { ...DEFAULT_QUOTE_PDF_APPEARANCE.labels, ...incoming.labels },
|
|
};
|
|
}
|
|
|
|
function applyTextStyle(doc: jsPDF, style: QuotePdfTextStyle, color: ColorTuple): void {
|
|
doc.setTextColor(...color);
|
|
doc.setFont(style.font, style.weight);
|
|
doc.setFontSize(style.size);
|
|
}
|
|
|
|
function spacingAmount(spacing: QuotePdfAppearance['serviceSpacing'], compact: number, normal: number, spacious: number): number {
|
|
if (spacing === 'compact') return compact;
|
|
if (spacing === 'spacious') return spacious;
|
|
return normal;
|
|
}
|
|
|
|
function hexToRgb(hex: string): ColorTuple {
|
|
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return GR;
|
|
const c = hex.replace('#', '');
|
|
const r = parseInt(c.substring(0, 2), 16);
|
|
const g = parseInt(c.substring(2, 4), 16);
|
|
const b = parseInt(c.substring(4, 6), 16);
|
|
if (isNaN(r) || isNaN(g) || isNaN(b)) return GR;
|
|
return [r, g, b];
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
function fmtDate(dateStr?: string): string {
|
|
const d = dateStr ? new Date(dateStr) : new Date();
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
function getLineHeight(fontSize: number, multiplier = 1.2): number {
|
|
return Math.ceil(fontSize * multiplier);
|
|
}
|
|
|
|
// ─── Inline-rich-text drawing ─────────────────────────────────────────────────
|
|
// Renders parsed {accent}/{muted}/{bold} segments horizontally with one
|
|
// color/font per segment. Supports left / center / right alignment so the
|
|
// footer line can keep its baseline layout whatever the markup is.
|
|
//
|
|
// `baseFont` / `baseSize` set the defaults applied before each segment; the
|
|
// accent color (`accent: ColorTuple`), the muted color (DG), the bold weight,
|
|
// and the base color (`baseColor: ColorTuple`) all toggle per segment.
|
|
//
|
|
// Word-wrapping is the caller's job — this draws a single line. The footer
|
|
// strings we use it on are short enough that no manual wrap is needed; if a
|
|
// user over-stuffs paymentTerms the right edge will clip (same as today).
|
|
interface DrawRichTextOptions {
|
|
doc: jsPDF;
|
|
segments: RichSegment[];
|
|
x: number;
|
|
y: number;
|
|
align: 'left' | 'center' | 'right';
|
|
baseColor: ColorTuple;
|
|
accent: ColorTuple;
|
|
font: QuotePdfTextStyle['font'];
|
|
size: number;
|
|
}
|
|
function drawRichText({ doc, segments, x, y, align, baseColor, accent, font, size }: DrawRichTextOptions): void {
|
|
doc.setFont(font, 'normal');
|
|
doc.setFontSize(size);
|
|
// Pre-compute the total width so we can anchor the first segment correctly
|
|
// for center/right alignment.
|
|
const widths = segments.map((s) => doc.getTextWidth(s.text));
|
|
const totalW = widths.reduce((a, b) => a + b, 0);
|
|
let cursor = align === 'right' ? x - totalW : align === 'center' ? x - totalW / 2 : x;
|
|
segments.forEach((seg, i) => {
|
|
const w = widths[i];
|
|
// Style → color + weight. `bold` stacks with accent/muted (the parser
|
|
// already collapsed the nesting, so we only ever see one style here).
|
|
let color: ColorTuple = baseColor;
|
|
let weight: 'normal' | 'bold' = 'normal';
|
|
if (seg.style === 'accent') color = accent;
|
|
else if (seg.style === 'muted') color = DG;
|
|
else if (seg.style === 'bold') { color = baseColor; weight = 'bold'; }
|
|
doc.setTextColor(...color);
|
|
doc.setFont(font, weight);
|
|
doc.text(seg.text, cursor, y);
|
|
cursor += w;
|
|
});
|
|
}
|
|
|
|
// ─── Page-break: add a new page, return to content-start y ────────────────────
|
|
// Does NOT draw the full header — only a thin continuation marker.
|
|
// Caller is responsible for redrawing section headers after the break.
|
|
function newPage(doc: jsPDF, _currentY: number): number {
|
|
doc.addPage();
|
|
return MARGIN; // start content at top margin on new page
|
|
}
|
|
|
|
// ─── Draw footer on a specific page ───────────────────────────────────────────
|
|
function drawFooter(doc: jsPDF, pageNum: number, totalPages: number, settings: ShopSettings, _shopChargeAmount: number) {
|
|
doc.setPage(pageNum);
|
|
const ACCENT: ColorTuple = settings.pdfAccentColor ? hexToRgb(settings.pdfAccentColor) : GR;
|
|
const appearance = quotePdfAppearance(settings);
|
|
|
|
// Payment terms — bottom-right, last page only, above the separator.
|
|
// Supports inline rich-text markup: {accent}…{/accent}, {muted}…{/muted},
|
|
// {bold}…{/bold}. When the string contains no tags we use the fast single
|
|
// draw to keep the byte-for-byte output identical to before.
|
|
if (appearance.showPaymentTerms && pageNum === totalPages && settings.paymentTerms) {
|
|
const label = `${appearance.labels.paymentTerms} `;
|
|
const full = label + settings.paymentTerms;
|
|
const py = PAGE_H - MARGIN - FOOTER_HEIGHT + 4; // 706 — within footer area, clear of content
|
|
if (hasRichText(full)) {
|
|
drawRichText({
|
|
doc,
|
|
segments: parseRichText(full),
|
|
x: PAGE_W - MARGIN,
|
|
y: py,
|
|
align: 'right',
|
|
baseColor: MG,
|
|
accent: ACCENT,
|
|
font: appearance.footer.font,
|
|
size: appearance.footer.size,
|
|
});
|
|
} else {
|
|
applyTextStyle(doc, appearance.footer, MG);
|
|
doc.text(full, PAGE_W - MARGIN, py, { align: 'right' });
|
|
}
|
|
}
|
|
|
|
// Separator line
|
|
const footerY = PAGE_H - MARGIN - FOOTER_HEIGHT + 12; // 714
|
|
doc.setDrawColor(...hexToRgb(appearance.colors.separator));
|
|
doc.setLineWidth(1);
|
|
doc.line(MARGIN, footerY, PAGE_W - MARGIN, footerY);
|
|
|
|
// Footer messages — same rich-text support as above.
|
|
if (appearance.showFooterMessage) {
|
|
const fm = settings.footerMessage || 'Thank you for choosing us for your automotive needs!';
|
|
if (hasRichText(fm)) {
|
|
drawRichText({
|
|
doc, segments: parseRichText(fm),
|
|
x: PAGE_W / 2, y: footerY + 15, align: 'center',
|
|
baseColor: hexToRgb(appearance.colors.footer), accent: ACCENT, font: appearance.footer.font, size: appearance.footer.size,
|
|
});
|
|
} else {
|
|
applyTextStyle(doc, appearance.footer, hexToRgb(appearance.colors.footer));
|
|
doc.text(fm, PAGE_W / 2, footerY + 15, { align: 'center' });
|
|
}
|
|
}
|
|
|
|
if (appearance.showQuoteFooterNote) {
|
|
const qfn = (settings.quoteFooterNote || 'This quote is valid for {days} days from the date above.').replace('{days}', String(settings.quoteExpiryDays || 30));
|
|
if (hasRichText(qfn)) {
|
|
drawRichText({
|
|
doc, segments: parseRichText(qfn),
|
|
x: PAGE_W / 2, y: footerY + 27, align: 'center',
|
|
baseColor: hexToRgb(appearance.colors.footer), accent: ACCENT, font: appearance.footer.font, size: appearance.footer.size,
|
|
});
|
|
} else {
|
|
applyTextStyle(doc, appearance.footer, hexToRgb(appearance.colors.footer));
|
|
doc.text(qfn, PAGE_W / 2, footerY + 27, { align: 'center' });
|
|
}
|
|
}
|
|
|
|
// Page number
|
|
doc.setTextColor(...MG);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.setFontSize(12);
|
|
doc.text(`Page ${pageNum} of ${totalPages}`, PAGE_W - MARGIN, PAGE_H - 10, { align: 'right' });
|
|
}
|
|
|
|
// ─── Main Generator ───────────────────────────────────────────────────────────
|
|
export async function generateQuotePDF(
|
|
quote: Quote,
|
|
settings: ShopSettings,
|
|
): Promise<Blob> {
|
|
const { jsPDF } = await import('jspdf');
|
|
const doc = new jsPDF('p', 'pt', 'letter');
|
|
|
|
const services: QuoteService[] = quote.services;
|
|
const customerName = quote.customerInfo.name || '';
|
|
if (!customerName || services.length === 0) {
|
|
throw new Error('Customer name and at least one service required');
|
|
}
|
|
|
|
// Pre-compute which services count toward totals
|
|
const billableIds = new Set(billableServices(services).map((s: any) => s.id));
|
|
|
|
const businessName = settings.businessName || 'Auto Repair Shop';
|
|
const businessAddress = settings.businessAddress || '123 Auto Lane\nKnoxville, TN 12345';
|
|
const businessPhone = settings.businessPhone || '(865) 555-0123';
|
|
const serviceAdvisor = quote.customerInfo.serviceAdvisor || settings.serviceAdvisor || '';
|
|
const technicianName = settings.defaultTechnician || '';
|
|
const providerLabel = getProviderLabel(settings);
|
|
const vehicleInfo = quote.customerInfo.vehicleInfo || '';
|
|
const roNumber = quote.customerInfo.roNumber || '';
|
|
const mileage = quote.customerInfo.mileage || '';
|
|
const vin = quote.customerInfo.vin || '';
|
|
const customerFirstName = customerName.split(' ')[0];
|
|
const isCompact = settings.pdfStyle === 'compact';
|
|
const appearance = quotePdfAppearance(settings);
|
|
const serviceGap = spacingAmount(appearance.serviceSpacing, 4, 8, 14);
|
|
const sectionGap = spacingAmount(appearance.sectionSpacing, 10, 16, 24);
|
|
const servicePriceDisplay = appearance.showServicePrices ? appearance.priceDisplay : 'hidden';
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// PAGE 1: FULL HEADER + MESSAGE
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
let y = MARGIN;
|
|
const leftColX = MARGIN;
|
|
const rightColX = MARGIN + (CW * 0.58);
|
|
const colWidth = CW * 0.40;
|
|
|
|
// Logo
|
|
if (settings.logoUrl && settings.showLogoOnPdf) {
|
|
const logoW = Math.max(12, appearance.logoWidth || 40);
|
|
const logoH = Math.max(8, appearance.logoHeight || (isCompact ? 16 : 20));
|
|
const logoX = appearance.logoAlignment === 'center'
|
|
? MARGIN + (CW - logoW) / 2
|
|
: appearance.logoAlignment === 'right'
|
|
? PAGE_W - MARGIN - logoW
|
|
: MARGIN;
|
|
doc.addImage(settings.logoUrl, 'PNG', logoX, y, logoW, logoH);
|
|
y += logoH + (isCompact ? 4 : 6);
|
|
}
|
|
|
|
const businessAddressLines = appearance.showBusinessAddress ? businessAddress
|
|
.split('\n')
|
|
.flatMap(line => doc.splitTextToSize(line, colWidth)) : [];
|
|
|
|
const allInfo: [string, string][] = [
|
|
['Customer', customerName], ['Vehicle', vehicleInfo], ['RO #', roNumber],
|
|
['Mileage', mileage], ['VIN', vin], ['Date', fmtDate(quote.createdAt)],
|
|
];
|
|
const infoItems = allInfo
|
|
.filter(([label, value]) => {
|
|
if (!appearance.showCustomerInfo) return false;
|
|
if (label === 'RO #' && !appearance.showRoNumber) return false;
|
|
if (label === 'Mileage' && !appearance.showMileage) return false;
|
|
if (label === 'VIN' && !appearance.showVin) return false;
|
|
if (label === 'Date' && !appearance.showQuoteDate) return false;
|
|
return value && value !== 'N/A' && value !== '';
|
|
})
|
|
.map(([label, value]) => ({
|
|
label,
|
|
valueLines: doc.splitTextToSize(value, colWidth - 20),
|
|
}));
|
|
|
|
const headerTop = y;
|
|
const headerPadding = isCompact ? 8 : 14;
|
|
let headerHeight = headerPadding;
|
|
|
|
// Measure left column height
|
|
headerHeight += getLineHeight(isCompact ? 7 : 8, 1.3);
|
|
headerHeight += getLineHeight(isCompact ? 10 : 11, 1.3);
|
|
headerHeight += businessAddressLines.length * getLineHeight(isCompact ? 8 : 9, 1.4);
|
|
const providerLineCount = [settings.showAdvisorOnQuote, settings.showTechnicianOnQuote].filter(Boolean).length;
|
|
headerHeight += getLineHeight(isCompact ? 8 : 9, 1.4) * ((appearance.showBusinessPhone ? 1 : 0) + providerLineCount);
|
|
headerHeight += isCompact ? 6 : 10;
|
|
|
|
// Measure right column height
|
|
let rightHeight = getLineHeight(isCompact ? 7 : 8, 1.3);
|
|
infoItems.forEach(({ valueLines }) => {
|
|
rightHeight += getLineHeight(isCompact ? 8 : 9, 1.4) + (valueLines.length - 1) * getLineHeight(isCompact ? 8 : 9, 1.4) + (isCompact ? 3 : 6);
|
|
});
|
|
headerHeight = Math.max(headerHeight, rightHeight + headerPadding * 2);
|
|
|
|
if (!isCompact && appearance.headerLayout === 'boxed') {
|
|
// Light gray background fill
|
|
doc.setFillColor(...hexToRgb(appearance.colors.headerBackground));
|
|
doc.roundedRect(MARGIN, MARGIN, CW, headerHeight, 4, 4, 'F');
|
|
}
|
|
|
|
// ── Left: service business ──
|
|
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8); doc.setFont('helvetica', 'bold');
|
|
doc.text(providerLabel, leftColX, y);
|
|
y += getLineHeight(isCompact ? 7 : 8, 1.3);
|
|
|
|
doc.setFont('helvetica', 'bold'); doc.setFontSize(isCompact ? 10 : 11); doc.setTextColor(...BL);
|
|
doc.text(businessName, leftColX, y);
|
|
y += getLineHeight(isCompact ? 10 : 11, 1.3);
|
|
|
|
doc.setTextColor(...DG); doc.setFont('helvetica', 'normal'); doc.setFontSize(isCompact ? 8 : 9);
|
|
businessAddressLines.forEach((line: string) => {
|
|
doc.text(line, leftColX, y);
|
|
y += getLineHeight(isCompact ? 8 : 9, 1.4);
|
|
});
|
|
if (businessAddressLines.length > 0) y += isCompact ? 6 : 10;
|
|
|
|
// ── Provider name(s) on quote: advisor and/or technician ──
|
|
if (settings.showAdvisorOnQuote) {
|
|
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8);
|
|
doc.text('Service Advisor', leftColX, y);
|
|
doc.setTextColor(...BL); doc.setFontSize(isCompact ? 8 : 9);
|
|
doc.text(serviceAdvisor || '—', leftColX + colWidth - 5, y, { align: 'right' });
|
|
y += getLineHeight(isCompact ? 8 : 9, 1.4);
|
|
}
|
|
|
|
if (settings.showTechnicianOnQuote) {
|
|
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8);
|
|
doc.text('Technician', leftColX, y);
|
|
doc.setTextColor(...BL); doc.setFontSize(isCompact ? 8 : 9);
|
|
doc.text(technicianName || '—', leftColX + colWidth - 5, y, { align: 'right' });
|
|
y += getLineHeight(isCompact ? 8 : 9, 1.4);
|
|
}
|
|
|
|
if (appearance.showBusinessPhone) {
|
|
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8);
|
|
doc.text('Phone', leftColX, y);
|
|
doc.setTextColor(...BL); doc.setFontSize(isCompact ? 8 : 9);
|
|
doc.text(businessPhone, leftColX + colWidth - 5, y, { align: 'right' });
|
|
}
|
|
|
|
const leftHeight = y;
|
|
|
|
// ── Right: CUSTOMER INFORMATION ──
|
|
let rY = headerTop;
|
|
if (appearance.showCustomerInfo && infoItems.length > 0 && appearance.headerLayout !== 'minimal') {
|
|
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8); doc.setFont('helvetica', 'bold');
|
|
doc.text('CUSTOMER INFORMATION', rightColX, rY);
|
|
rY += getLineHeight(isCompact ? 7 : 8, 1.3);
|
|
|
|
doc.setFont('helvetica', 'normal'); doc.setFontSize(isCompact ? 8 : 9);
|
|
infoItems.forEach(({ label, valueLines }) => {
|
|
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8);
|
|
doc.text(label, rightColX, rY);
|
|
|
|
doc.setTextColor(...BL); doc.setFontSize(isCompact ? 8 : 9);
|
|
doc.text(valueLines[0], rightColX + colWidth - 5, rY, { align: 'right' });
|
|
rY += getLineHeight(isCompact ? 8 : 9, 1.4);
|
|
|
|
for (let i = 1; i < valueLines.length; i++) {
|
|
doc.text(valueLines[i], rightColX + colWidth - 5, rY, { align: 'right' });
|
|
rY += getLineHeight(isCompact ? 8 : 9, 1.4);
|
|
}
|
|
rY += isCompact ? 3 : 6;
|
|
});
|
|
}
|
|
|
|
y = Math.max(leftHeight, rY) + (isCompact ? 10 : 16);
|
|
|
|
if (!isCompact && appearance.headerLayout === 'boxed') {
|
|
// Header border
|
|
doc.setDrawColor(...hexToRgb(appearance.colors.separator)); doc.setLineWidth(0.7);
|
|
doc.roundedRect(MARGIN, MARGIN, CW, y - MARGIN, 4, 4, 'D');
|
|
}
|
|
// Separator
|
|
doc.setDrawColor(200, 200, 200); doc.line(MARGIN + (isCompact ? 0 : 4), y, PAGE_W - MARGIN - (isCompact ? 0 : 4), y);
|
|
y += isCompact ? 12 : 20;
|
|
|
|
// ── Personalized message ──
|
|
if (!isCompact && appearance.showHeaderMessage) {
|
|
doc.setTextColor(...MG); doc.setFont('helvetica', 'normal'); doc.setFontSize(10);
|
|
const headerMsg = (settings.headerMessage ||
|
|
'Hi {customerName}, here is the explanation of the services and repairs that your technician has recommended for your {vehicleInfo}. If you have any questions, please feel free to ask.')
|
|
.replace('{customerName}', customerFirstName).replace('{vehicleInfo}', vehicleInfo);
|
|
doc.splitTextToSize(headerMsg, CW).forEach((line: string) => {
|
|
doc.text(line, MARGIN, y);
|
|
y += getLineHeight(10, 1.4);
|
|
});
|
|
y += 18;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// SERVICES SECTION — with proper page-break logic
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
let servicesTotal = 0;
|
|
const approvedSvcs = services.filter(s => s.customerDecision === 'approved');
|
|
const pendingSvcs = appearance.showPendingRecommendations ? services.filter(s => s.customerDecision === 'pending') : [];
|
|
const declinedSvcs = appearance.showDeclinedServices ? services.filter(s => s.customerDecision === 'declined') : [];
|
|
const approvedColor = hexToRgb(appearance.colors.sectionHeader);
|
|
const recommendationsColor = hexToRgb(appearance.colors.recommendationsSection);
|
|
const declinedColor = MG;
|
|
const separatorColor = hexToRgb(appearance.colors.separator);
|
|
|
|
interface SvcGroup { label: string | null; services: QuoteService[]; color: ColorTuple | null; }
|
|
const groups: SvcGroup[] = [];
|
|
if (appearance.sectionOrder === 'combined') {
|
|
groups.push({ label: null, services: [...approvedSvcs, ...pendingSvcs, ...declinedSvcs], color: null });
|
|
} else if (appearance.sectionOrder === 'recommendations_first') {
|
|
if (pendingSvcs.length > 0) groups.push({ label: appearance.labels.recommendationsSection, services: pendingSvcs, color: recommendationsColor });
|
|
if (approvedSvcs.length > 0) groups.push({ label: appearance.labels.approvedSection, services: approvedSvcs, color: approvedColor });
|
|
if (declinedSvcs.length > 0) groups.push({ label: appearance.labels.declinedSection, services: declinedSvcs, color: declinedColor });
|
|
} else {
|
|
if (approvedSvcs.length > 0) groups.push({ label: appearance.labels.approvedSection, services: approvedSvcs, color: approvedColor });
|
|
if (pendingSvcs.length > 0) groups.push({ label: appearance.labels.recommendationsSection, services: pendingSvcs, color: recommendationsColor });
|
|
if (declinedSvcs.length > 0) groups.push({ label: appearance.labels.declinedSection, services: declinedSvcs, color: declinedColor });
|
|
}
|
|
|
|
// If only one section exists, hide the section label for a cleaner look
|
|
if (groups.length === 1) groups[0].label = null;
|
|
|
|
// Track current section for redrawing header after page breaks
|
|
let currentGroupLabel: string | null = null;
|
|
let currentGroupColor: ColorTuple | null = null;
|
|
|
|
groups.forEach((group: SvcGroup) => {
|
|
currentGroupLabel = group.label;
|
|
currentGroupColor = group.color;
|
|
|
|
// Draw section header
|
|
if (group.label && group.color) {
|
|
if (y + getLineHeight(appearance.sectionHeader.size, 1.3) + sectionGap > CONTENT_BOTTOM - FOOTER_HEIGHT) y = newPage(doc, y);
|
|
applyTextStyle(doc, appearance.sectionHeader, group.color);
|
|
doc.text(group.label, MARGIN, y);
|
|
y += getLineHeight(appearance.sectionHeader.size, 1.3);
|
|
doc.setDrawColor(...group.color); doc.setLineWidth(0.7);
|
|
doc.line(MARGIN, y, PAGE_W - MARGIN, y);
|
|
y += sectionGap;
|
|
}
|
|
|
|
group.services.forEach((svc: QuoteService, idx: number) => {
|
|
// ── Calculate this service's total height ──
|
|
let svcH = 0;
|
|
if (appearance.showServiceDecisionBadges && (svc.customerDecision === 'approved' || svc.customerDecision === 'declined')) svcH += getLineHeight(8, 1.3);
|
|
if (appearance.showAuthorizationLine && (svc.authorizedBy || svc.authorizedMethod)) svcH += getLineHeight(appearance.authorization.size, 1.2);
|
|
svcH += getLineHeight(appearance.serviceTitle.size, 1.3); // name + price
|
|
if (appearance.showPriority && svc.priority) svcH += getLineHeight(appearance.priority.size);
|
|
if (appearance.showPriorityReason && svc.priorityReason) svcH += getLineHeight(appearance.priority.size);
|
|
if (appearance.showRecommendation && svc.recommendation) svcH += getLineHeight(appearance.recommendation.size, 1.2);
|
|
if (appearance.showServiceDescription && svc.explanation) svcH += doc.splitTextToSize(svc.explanation, CW).length * getLineHeight(appearance.serviceDescription.size, 1.4) + 2;
|
|
if (appearance.showPartsStatus && svc.partsNotInStock) { svcH += getLineHeight(10); if (svc.partsDeliveryTime) svcH += getLineHeight(10); }
|
|
if (appearance.showAftermarketOptions && svc.aftermarketAvailable) { svcH += getLineHeight(10); if (svc.aftermarketPartsList) svcH += getLineHeight(9); }
|
|
svcH += serviceGap + (appearance.serviceSeparators ? 4 : 0);
|
|
|
|
// ── PAGE BREAK if this service won't fit ──
|
|
if (y + svcH > CONTENT_BOTTOM - FOOTER_HEIGHT) {
|
|
y = newPage(doc, y);
|
|
// Redraw section header on new page
|
|
if (currentGroupLabel && currentGroupColor) {
|
|
applyTextStyle(doc, appearance.sectionHeader, currentGroupColor);
|
|
doc.text(currentGroupLabel, MARGIN, y); y += getLineHeight(appearance.sectionHeader.size, 1.1);
|
|
doc.setDrawColor(...currentGroupColor); doc.setLineWidth(1);
|
|
doc.line(MARGIN, y, PAGE_W - MARGIN, y); y += sectionGap;
|
|
}
|
|
}
|
|
|
|
// ── Render service ──
|
|
// Decision badge
|
|
if (appearance.showServiceDecisionBadges && svc.customerDecision === 'approved') {
|
|
doc.setTextColor(...hexToRgb(appearance.colors.approvedBadge)); doc.setFont('helvetica', 'bold'); doc.setFontSize(8);
|
|
doc.text(`\u2713 ${appearance.labels.customerApproved}`, MARGIN, y); y += getLineHeight(8);
|
|
} else if (appearance.showServiceDecisionBadges && svc.customerDecision === 'declined') {
|
|
doc.setTextColor(...hexToRgb(appearance.colors.declinedBadge)); doc.setFont('helvetica', 'bold'); doc.setFontSize(8);
|
|
doc.text(`\u2717 ${appearance.labels.customerDeclined}`, MARGIN, y); y += getLineHeight(8);
|
|
}
|
|
if (appearance.showAuthorizationLine && (svc.authorizedBy || svc.authorizedMethod)) {
|
|
applyTextStyle(doc, appearance.authorization, MG);
|
|
doc.text(authorizationText(svc, appearance), MARGIN + 2, y); y += getLineHeight(appearance.authorization.size, 1.2);
|
|
}
|
|
|
|
// Name + price (same baseline)
|
|
applyTextStyle(doc, appearance.serviceTitle, hexToRgb(appearance.colors.serviceTitle));
|
|
const price = parseFloat(String(svc.price ?? 0)) || 0;
|
|
const title = servicePriceDisplay === 'inline' ? `${svc.name} - $${price.toFixed(2)}` : svc.name;
|
|
doc.text(title, MARGIN, y);
|
|
if (svc.customerDecision === 'declined') {
|
|
const nw = doc.getTextWidth(title);
|
|
doc.setDrawColor(...separatorColor); doc.setLineWidth(0.5);
|
|
doc.line(MARGIN, y - 1, MARGIN + nw, y - 1);
|
|
}
|
|
// Count toward totals based on billableServices heuristic
|
|
if (billableIds.has(svc.id)) servicesTotal += price;
|
|
if (servicePriceDisplay === 'right') doc.text(`$${price.toFixed(2)}`, PAGE_W - MARGIN, y, { align: 'right' });
|
|
y += getLineHeight(appearance.serviceTitle.size, 1.3);
|
|
|
|
// Priority
|
|
if (appearance.showPriority && svc.priority) {
|
|
const pc: Record<string, { icon: string; label: string }> = {
|
|
CRITICAL_SAFETY: { icon: '[CRITICAL]', label: 'CRITICAL SAFETY PRIORITY' },
|
|
SAFETY_CONCERN: { icon: '[SAFETY]', label: 'SAFETY CONCERN PRIORITY' },
|
|
RECOMMENDED: { icon: '[RECOMMENDED]', label: 'RECOMMENDED PRIORITY' },
|
|
MAINTENANCE: { icon: '[MAINTENANCE]', label: 'MAINTENANCE PRIORITY' },
|
|
OPTIONAL: { icon: '[OPTIONAL]', label: 'OPTIONAL PRIORITY' },
|
|
};
|
|
const c = pc[svc.priority] || pc.RECOMMENDED;
|
|
applyTextStyle(doc, appearance.priority, DG);
|
|
doc.text(`${c.icon} ${c.label}`, MARGIN, y); y += getLineHeight(9);
|
|
if (appearance.showPriorityReason && svc.priorityReason) {
|
|
doc.setFont(appearance.priority.font, 'normal'); doc.setFontSize(appearance.priority.size);
|
|
doc.setTextColor(...hexToRgb(appearance.colors.description));
|
|
doc.text(`${appearance.labels.priorityReason} ${svc.priorityReason}`, MARGIN, y); y += getLineHeight(appearance.priority.size);
|
|
}
|
|
}
|
|
|
|
// Recommendation
|
|
if (appearance.showRecommendation && svc.recommendation) {
|
|
applyTextStyle(doc, appearance.recommendation, hexToRgb(appearance.colors.recommendation));
|
|
const recommendationText = svc.recommendationReason || svc.recommendation || '';
|
|
doc.text(appearance.recommendationTransform === 'uppercase' ? recommendationText.toUpperCase() : recommendationText, MARGIN, y);
|
|
y += getLineHeight(appearance.recommendation.size, 1.2);
|
|
}
|
|
|
|
// Explanation
|
|
if (appearance.showServiceDescription && svc.explanation) {
|
|
applyTextStyle(doc, appearance.serviceDescription, hexToRgb(appearance.colors.description));
|
|
doc.splitTextToSize(svc.explanation, CW).forEach((line: string) => {
|
|
doc.text(line, MARGIN, y); y += getLineHeight(appearance.serviceDescription.size, 1.4);
|
|
});
|
|
y += serviceGap;
|
|
}
|
|
|
|
// Parts status flags
|
|
if (appearance.showPartsStatus && svc.partsNotInStock) {
|
|
doc.setTextColor(...OR); doc.setFont('helvetica', 'bold'); doc.setFontSize(9);
|
|
doc.text('NOTE: Parts for this service are not in stock and will need to be ordered.', MARGIN, y);
|
|
y += getLineHeight(9);
|
|
if (svc.partsDeliveryTime) {
|
|
doc.text(`Estimated Delivery: ${svc.partsDeliveryTime}`, MARGIN, y); y += getLineHeight(9);
|
|
}
|
|
}
|
|
if (appearance.showAftermarketOptions && svc.aftermarketAvailable) {
|
|
doc.setTextColor(...DG); doc.setFont('helvetica', 'bold'); doc.setFontSize(9);
|
|
doc.text('NOTE: Aftermarket parts are available for this service.', MARGIN, y);
|
|
y += getLineHeight(9);
|
|
if (svc.aftermarketPartsList) {
|
|
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
|
|
doc.text(`Aftermarket Options: ${svc.aftermarketPartsList}`, MARGIN, y); y += getLineHeight(8);
|
|
}
|
|
}
|
|
|
|
// Separator between services
|
|
if (appearance.serviceSeparators && idx < group.services.length - 1) {
|
|
doc.setDrawColor(...separatorColor); doc.setLineWidth(0.5);
|
|
doc.line(MARGIN, y + 3, PAGE_W - MARGIN, y + 3);
|
|
}
|
|
y += serviceGap;
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// PRICING SUMMARY — with page-break if needed
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
const discountValue = parseFloat(String(quote.discount.value)) || 0;
|
|
const discountType = quote.discount.type || 'dollar';
|
|
|
|
// Determine whether we have a mix of approved + pending (RO transfer scenario)
|
|
const nonDeclined = services.filter(s => s.customerDecision !== 'declined');
|
|
const hasMixed = nonDeclined.some(s => s.customerDecision === 'approved') && nonDeclined.some(s => s.customerDecision === 'pending');
|
|
|
|
// Compute split totals when mixed; fall back to current billable-only totals
|
|
let approvedTotals: TotalsBreakdown | null = null;
|
|
let pendingTotals: TotalsBreakdown | null = null;
|
|
let combinedTotals: TotalsBreakdown | null = null;
|
|
let displaySubtotal = servicesTotal;
|
|
let displayShopCharge = 0;
|
|
let displayDiscount = 0;
|
|
let displayTax = 0;
|
|
let displayTotal = 0;
|
|
const showTax = appearance.showTaxLine && settings.taxEnabled !== false && settings.taxRate > 0;
|
|
|
|
if (hasMixed) {
|
|
const split = computeSplitQuoteTotals(services, quote.discount || { value: 0, type: 'dollar' }, settings);
|
|
approvedTotals = split.approved;
|
|
pendingTotals = split.pending;
|
|
combinedTotals = split.combined;
|
|
displaySubtotal = split.combined.subtotal;
|
|
displayDiscount = split.combined.discountAmount;
|
|
displayShopCharge = split.combined.shopCharge;
|
|
displayTax = split.combined.tax;
|
|
displayTotal = split.combined.total;
|
|
} else {
|
|
// Existing billable-only totals (backward compat)
|
|
const shopChargeableSubtotal = services.reduce(
|
|
(acc, s) => (billableIds.has(s.id) && s.applyShopCharge !== false)
|
|
? acc + (parseFloat(String(s.price)) || 0)
|
|
: acc, 0);
|
|
const shopChargeRate = settings.shopChargeRate || 0;
|
|
const shopChargeCap = settings.shopChargeCap ?? 69.95;
|
|
displayShopCharge = Math.min(shopChargeableSubtotal * (shopChargeRate / 100), shopChargeCap);
|
|
|
|
if (discountValue > 0) {
|
|
displayDiscount = discountType === 'percent' ? servicesTotal * (discountValue / 100) : discountValue;
|
|
}
|
|
const taxRate = settings.taxEnabled === false ? 0 : settings.taxRate || 0;
|
|
displayTax = (servicesTotal - displayDiscount + displayShopCharge) * (taxRate / 100);
|
|
displayTotal = servicesTotal - displayDiscount + displayShopCharge + displayTax;
|
|
}
|
|
|
|
if (appearance.showTotalsSummary) {
|
|
const sumX = PAGE_W - MARGIN - (CW * 0.45);
|
|
const sumW = CW * 0.45;
|
|
const sumRight = PAGE_W - MARGIN;
|
|
const bp = 10;
|
|
const mixedSectionGap = 3;
|
|
|
|
// Count rows for height calculation
|
|
let sumRows = 0;
|
|
if (hasMixed) {
|
|
sumRows += 4; // approved: subtotal + shopCharge (cond) + tax (cond) + approved total
|
|
sumRows += 4; // pending: subtotal + shopCharge (cond) + tax (cond) + if approved
|
|
sumRows += 1; // combined subtotal
|
|
if (appearance.showDiscountLine && displayDiscount > 0) sumRows += 1; // combined discount
|
|
if (appearance.showShopChargeLine && displayShopCharge > 0) sumRows += 1; // combined capped shop charge
|
|
if (showTax) sumRows += 1; // combined recalculated tax
|
|
sumRows += 2; // separator + grand total
|
|
} else {
|
|
sumRows = 1; // subtotal
|
|
if (appearance.showDiscountLine && displayDiscount > 0) sumRows++;
|
|
if (appearance.showShopChargeLine && displayShopCharge > 0) sumRows++;
|
|
if (showTax) sumRows++;
|
|
sumRows += 2; // separator + total
|
|
}
|
|
const sumContentH = (sumRows * getLineHeight(9, 1.2)) + 14 + (hasMixed ? mixedSectionGap * 2 : 0);
|
|
const sumTotalH = bp + sumContentH + bp;
|
|
|
|
// Check if summary + shop charge note fits; if not, page break
|
|
const noteExtra = displayShopCharge > 0 ? 30 : 0;
|
|
if (y + sumTotalH + 14 + noteExtra > CONTENT_BOTTOM - FOOTER_HEIGHT) {
|
|
y = newPage(doc, y);
|
|
}
|
|
|
|
y += 14;
|
|
const sumTopY = y - bp;
|
|
|
|
doc.setFillColor(...hexToRgb(appearance.colors.totalsBackground)); doc.setDrawColor(...hexToRgb(appearance.colors.separator)); doc.setLineWidth(0.5);
|
|
doc.roundedRect(sumX - bp, sumTopY, sumW + (bp * 2), sumTotalH, 4, 4, 'FD');
|
|
y = sumTopY + bp;
|
|
|
|
if (hasMixed && approvedTotals && pendingTotals && combinedTotals) {
|
|
// ── Approved Service Totals ──
|
|
doc.setTextColor(...GR); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(Math.max(7, appearance.totals.size - 2));
|
|
doc.text('ALREADY APPROVED WORK', sumX, y);
|
|
applyTextStyle(doc, appearance.totals, BL);
|
|
doc.text(`$${approvedTotals.subtotal.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
|
|
if (appearance.showShopChargeLine && approvedTotals.shopCharge > 0) {
|
|
applyTextStyle(doc, appearance.totals, DG);
|
|
doc.text(appearance.labels.shopCharge, sumX, y);
|
|
doc.setTextColor(...BL);
|
|
doc.text(`$${approvedTotals.shopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
}
|
|
if (showTax) {
|
|
applyTextStyle(doc, appearance.totals, DG);
|
|
doc.text(settings.taxLabel || 'Tax', sumX, y);
|
|
doc.setTextColor(...BL);
|
|
doc.text(`$${approvedTotals.tax.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
}
|
|
doc.setTextColor(...GR); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(appearance.totals.size);
|
|
doc.text(appearance.labels.authorizedTotal, sumX, y);
|
|
doc.text(`$${approvedTotals.total.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(10) + mixedSectionGap;
|
|
|
|
// ── Additional Recommendations ──
|
|
doc.setTextColor(...BU); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(Math.max(7, appearance.totals.size - 2));
|
|
doc.text('ADDITIONAL RECOMMENDATIONS', sumX, y);
|
|
applyTextStyle(doc, appearance.totals, BL);
|
|
doc.text(`$${pendingTotals.subtotal.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
|
|
if (appearance.showShopChargeLine && pendingTotals.shopCharge > 0) {
|
|
applyTextStyle(doc, appearance.totals, DG);
|
|
doc.text(`Estimated ${appearance.labels.shopCharge.toLowerCase()}`, sumX, y);
|
|
doc.setTextColor(...BL);
|
|
doc.text(`$${pendingTotals.shopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
}
|
|
if (showTax) {
|
|
applyTextStyle(doc, appearance.totals, DG);
|
|
doc.text(settings.taxLabel || 'Tax', sumX, y);
|
|
doc.setTextColor(...BL);
|
|
doc.text(`$${pendingTotals.tax.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
}
|
|
doc.setTextColor(...BU); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(appearance.totals.size);
|
|
doc.text(appearance.labels.addOnTotal, sumX, y);
|
|
doc.text(`$${pendingTotals.total.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(10) + mixedSectionGap;
|
|
|
|
// ── Combined: Total if All Approved ──
|
|
doc.setTextColor(...DG); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(appearance.totals.size);
|
|
doc.text(appearance.labels.allWorkSubtotal, sumX, y);
|
|
doc.setTextColor(...GR);
|
|
doc.text(`$${combinedTotals.subtotal.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
|
|
// Discount
|
|
if (appearance.showDiscountLine && displayDiscount > 0) {
|
|
applyTextStyle(doc, appearance.totals, DG);
|
|
doc.text(appearance.labels.discount, sumX, y);
|
|
doc.setTextColor(...GR);
|
|
doc.text(`\u2212$${displayDiscount.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
}
|
|
|
|
if (appearance.showShopChargeLine && displayShopCharge > 0) {
|
|
applyTextStyle(doc, appearance.totals, DG);
|
|
const cap = settings.shopChargeCap ?? 69.95;
|
|
doc.text(`${appearance.labels.shopCharge}, capped at $${cap.toFixed(2)}`, sumX, y);
|
|
doc.setTextColor(...GR);
|
|
doc.text(`$${displayShopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
}
|
|
|
|
if (showTax) {
|
|
applyTextStyle(doc, appearance.totals, DG);
|
|
doc.text(`${settings.taxLabel || 'Tax'} @ ${settings.taxRate}%`, sumX, y);
|
|
doc.setTextColor(...GR);
|
|
doc.text(`$${displayTax.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
}
|
|
|
|
// Separator + Grand Total
|
|
doc.setDrawColor(...hexToRgb(appearance.colors.separator)); doc.setLineWidth(0.5);
|
|
doc.line(sumX, y, sumRight, y); y += getLineHeight(10);
|
|
doc.setTextColor(...BL); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(Math.max(10, appearance.totals.size));
|
|
doc.text(appearance.labels.totalIfAllApproved, sumX, y);
|
|
doc.text(`$${displayTotal.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
} else {
|
|
// ── Simple single-section pricing summary (existing layout) ──
|
|
// Subtotal
|
|
applyTextStyle(doc, appearance.totals, DG); doc.text(appearance.labels.subtotal, sumX, y);
|
|
doc.setTextColor(...BL); doc.text(`$${displaySubtotal.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
|
|
// Discount
|
|
if (appearance.showDiscountLine && displayDiscount > 0) {
|
|
applyTextStyle(doc, appearance.totals, DG);
|
|
doc.text(appearance.labels.discount, sumX, y);
|
|
doc.setTextColor(...GR);
|
|
doc.text(`\u2212$${displayDiscount.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9); doc.setTextColor(...BL);
|
|
}
|
|
|
|
// Shop charge
|
|
if (appearance.showShopChargeLine && displayShopCharge > 0) {
|
|
applyTextStyle(doc, appearance.totals, DG);
|
|
doc.text(appearance.labels.shopCharge, sumX, y);
|
|
doc.setTextColor(...BL);
|
|
doc.text(`$${displayShopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
}
|
|
|
|
// Tax
|
|
if (showTax) {
|
|
applyTextStyle(doc, appearance.totals, DG);
|
|
doc.text(settings.taxLabel || 'Tax', sumX, y);
|
|
doc.setTextColor(...BL);
|
|
doc.text(`$${displayTax.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
y += getLineHeight(9);
|
|
}
|
|
|
|
// Separator + Total
|
|
doc.setDrawColor(...hexToRgb(appearance.colors.separator)); doc.setLineWidth(0.5);
|
|
doc.line(sumX, y, sumRight, y); y += getLineHeight(10);
|
|
doc.setTextColor(...BL); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(Math.max(10, appearance.totals.size));
|
|
doc.text(appearance.labels.total, sumX, y);
|
|
doc.text(`$${displayTotal.toFixed(2)}`, sumRight, y, { align: 'right' });
|
|
}
|
|
|
|
y = sumTopY + sumTotalH + 10;
|
|
|
|
// Shop charge explanation
|
|
if (appearance.showShopChargeLine && displayShopCharge > 0) {
|
|
doc.setTextColor(...MG); doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
|
|
const note = `* ${settings.shopChargeExplanation || 'Shop Charge covers consumable supplies such as shop rags, cleaning solvents, lubricants, and other miscellaneous materials used during service.'}`;
|
|
doc.splitTextToSize(note, sumW).forEach((line: string) => { doc.text(line, sumX, y); y += getLineHeight(8); });
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// FOOTERS — draw on every page
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
const totalPages = doc.getNumberOfPages();
|
|
for (let i = 1; i <= totalPages; i++) {
|
|
drawFooter(doc, i, totalPages, settings, displayShopCharge);
|
|
}
|
|
|
|
return doc.output('blob');
|
|
}
|
|
|
|
// ── Download & Print Helpers ─────────────────────────────────────────────────
|
|
export async function downloadQuotePDF(quote: Quote, settings: ShopSettings): Promise<void> {
|
|
const blob = await generateQuotePDF(quote, settings);
|
|
const sn = (quote.customerInfo.name || 'Customer').replace(/[^a-zA-Z0-9_-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
const sr = (quote.customerInfo.roNumber || 'NRO').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const ds = fmtDate(quote.createdAt).replace(/,/g, '').replace(/\s+/g, '_');
|
|
const fn = `Quote_${sr}_${sn}_${ds}.pdf`;
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a'); a.href = url; a.download = fn;
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export async function printQuotePDF(quote: Quote, settings: ShopSettings): Promise<void> {
|
|
const blob = await generateQuotePDF(quote, settings);
|
|
const url = URL.createObjectURL(blob);
|
|
const win = window.open(url, '_blank');
|
|
if (win) win.addEventListener('load', () => win.print());
|
|
}
|