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 = { 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 { 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 = { 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 { 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 { const blob = await generateQuotePDF(quote, settings); const url = URL.createObjectURL(blob); const win = window.open(url, '_blank'); if (win) win.addEventListener('load', () => win.print()); }