initial commit

This commit is contained in:
ray
2026-07-12 10:01:39 -04:00
commit fc8290d668
185 changed files with 38831 additions and 0 deletions
+837
View File
@@ -0,0 +1,837 @@
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;
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.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 (appearance.showTaxLine && settings.taxRate > 0) 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 (appearance.showTaxLine && settings.taxRate > 0) 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 (appearance.showTaxLine && settings.taxRate > 0) {
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 (appearance.showTaxLine && settings.taxRate > 0) {
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 (appearance.showTaxLine && settings.taxRate > 0) {
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 (appearance.showTaxLine && settings.taxRate > 0) {
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());
}