/** * estimateExport.js * Utilities for exporting estimates via WhatsApp and PDF. */ // --------------------------------------------------------------------------- // Line item computation — single source of truth for all estimate totals // Mirrors EstimateBuilder's handleMeasurementsNext logic. // --------------------------------------------------------------------------- export function computeLineItems(template, roofArea) { if (!template || !roofArea) return { materials: [], labor: [] }; const waste = 1.05; const margin = 1.428; // ~30% margin const ridgesLF = Math.round(roofArea * 1.6); const eavesLF = Math.round(roofArea * 4.2); const materials = template.materials.map(mat => { let qty; if (mat.uom === 'SQ') { qty = Math.ceil(roofArea * waste); } else if (mat.uom === 'BD') { const isRidge = /ridge|hip/i.test(mat.desc); qty = isRidge ? Math.max(1, Math.ceil((ridgesLF * waste) / 30)) : Math.max(1, Math.ceil((eavesLF * waste) / 100)); } else if (mat.uom === 'RL') { const isIce = /ice|water/i.test(mat.desc); qty = isIce ? Math.max(1, Math.ceil((eavesLF * waste) / 65)) : Math.max(1, Math.ceil((roofArea * waste) / 10)); } else if (mat.uom === 'LF') { qty = Math.ceil(eavesLF * waste); } else if (mat.uom === 'BX') { qty = Math.max(1, Math.ceil(roofArea / 20)); } else if (mat.uom === 'EA') { qty = 2; } else { qty = 1; } const clientPrice = Math.round(qty * mat.baseUnitCost * margin * 100) / 100; return { desc: mat.desc, qty, uom: mat.uom, unitCost: mat.baseUnitCost, clientPrice }; }); const laborQty = Math.ceil(roofArea * waste); const laborUnitCost = 80.00; const labor = [{ desc: `Tear Off & Install — ${template.name}`, qty: laborQty, uom: 'SQ', unitCost: laborUnitCost, clientPrice: Math.round(laborQty * laborUnitCost * margin * 100) / 100, }]; return { materials, labor }; } export function computeGrandTotal(template, roofArea) { const { materials, labor } = computeLineItems(template, roofArea); return ( materials.reduce((s, m) => s + m.clientPrice, 0) + labor.reduce((s, l) => s + l.clientPrice, 0) ); } // --------------------------------------------------------------------------- // WhatsApp — detailed text message // --------------------------------------------------------------------------- export function formatWhatsAppMessage(estimate, template, materials, labor) { const fmt = (n) => `$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 2 })}`; const divider = '━━━━━━━━━━━━━━━━━━━━━━'; const lines = []; // Header lines.push(`*ESTIMATE — LynkedUpPro Roofing*`); lines.push(divider); lines.push(''); lines.push(`*Client:* ${estimate.clientName}`); lines.push(`*Address:* ${estimate.address}`); lines.push(`*Date:* ${estimate.date}`); lines.push(`*Status:* ${estimate.status}`); if (estimate.templateName) lines.push(`*Template:* ${estimate.templateName}`); if (estimate.roofArea) lines.push(`*Roof Area:* ${estimate.roofArea} SQ`); if (estimate.estimatedBy) lines.push(`*Estimated By:* ${estimate.estimatedBy}`); // Notes if (estimate.notes) { lines.push(''); lines.push(`_Note: ${estimate.notes}_`); } // Scope of work if (template?.description?.length) { lines.push(''); lines.push(divider); lines.push('*SCOPE OF WORK*'); lines.push(divider); template.description.forEach((line) => { lines.push(`▸ ${line}`); }); } // Materials if (materials.length) { lines.push(''); lines.push(divider); lines.push('*MATERIALS*'); lines.push(divider); materials.forEach((m) => { const unitPrice = m.clientPrice / m.qty; lines.push(`• ${m.desc} — ${m.qty} ${m.uom} @ ${fmt(unitPrice)} = *${fmt(m.clientPrice)}*`); }); const matTotal = materials.reduce((s, m) => s + m.clientPrice, 0); lines.push(` _Subtotal: ${fmt(matTotal)}_`); } // Labor if (labor.length) { lines.push(''); lines.push(divider); lines.push('*LABOR*'); lines.push(divider); labor.forEach((l) => { const unitPrice = l.clientPrice / l.qty; lines.push(`• ${l.desc} — ${l.qty} ${l.uom} @ ${fmt(unitPrice)} = *${fmt(l.clientPrice)}*`); }); const laborTotal = labor.reduce((s, l) => s + l.clientPrice, 0); lines.push(` _Subtotal: ${fmt(laborTotal)}_`); } // Financial summary const matTotal = materials.reduce((s, m) => s + m.clientPrice, 0); const laborTotal = labor.reduce((s, l) => s + l.clientPrice, 0); const grandTotal = matTotal + laborTotal; lines.push(''); lines.push(divider); lines.push('*FINANCIAL SUMMARY*'); lines.push(divider); if (materials.length) lines.push(`Materials: ${fmt(matTotal)}`); if (labor.length) lines.push(`Labor: ${fmt(laborTotal)}`); lines.push(`*TOTAL: ${fmt(grandTotal)}*`); // Footer lines.push(''); lines.push(divider); lines.push('_This estimate is valid for 30 days from the date above._'); lines.push('_LynkedUpPro Roofing | Plano, TX | lynkeduppro.com_'); return lines.join('\n'); } export function openWhatsApp(message) { const url = `https://wa.me/?text=${encodeURIComponent(message)}`; window.open(url, '_blank', 'noopener,noreferrer'); } export function openIMessage(message) { // sms: scheme opens Messages on iOS/macOS; body pre-fills the message window.open(`sms:?&body=${encodeURIComponent(message)}`, '_blank', 'noopener,noreferrer'); } export function openEmail(estimate, message) { const subject = `Estimate for ${estimate.clientName} — ${estimate.date}`; const url = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(message)}`; window.open(url, '_blank', 'noopener,noreferrer'); } // --------------------------------------------------------------------------- // PDF — light-mode, color-coded // --------------------------------------------------------------------------- // Color palette const C = { // Blues — header + materials blueDark: [30, 58, 138], // #1E3A8A blue: [59, 130, 246], // #3B82F6 blueLight: [239, 246, 255], // #EFF6FF blueMid: [219, 234, 254], // #DBEAFE // Purples — labor purpleDark: [88, 28, 135], // #581C87 purple: [139, 92, 246], // #8B5CF6 purpleLight:[250, 245, 255], // #FAF5FF purpleMid: [237, 233, 254], // #EDE9FE // Greens — totals greenDark: [5, 150, 105], // #059669 greenLight: [236, 253, 245], // #ECFDF5 // Neutrals zinc900: [24, 24, 27], // text zinc700: [63, 63, 70], zinc500: [113, 113, 122], zinc400: [161, 161, 170], zinc200: [228, 228, 231], zinc100: [244, 244, 245], white: [255, 255, 255], // Amber — notes amberLight: [255, 251, 235], // #FFFBEB amberBorder:[251, 191, 36], // #FBBf24 amber800: [146, 64, 14], // #92400E }; function rgb(arr) { return { r: arr[0], g: arr[1], b: arr[2] }; } function setFill(doc, arr) { doc.setFillColor(...arr); } function setDraw(doc, arr) { doc.setDrawColor(...arr); } function setTextColor(doc, arr) { doc.setTextColor(...arr); } // --------------------------------------------------------------------------- export async function generateEstimatePDF(estimate, template, materials, labor) { // Dynamic import so jsPDF doesn't bloat the initial bundle const { default: jsPDF } = await import('jspdf'); const { default: autoTable } = await import('jspdf-autotable'); const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); const PW = 210; // page width const PH = 297; // page height const ML = 15; // margin left const MR = 15; // margin right const CW = PW - ML - MR; // content width let y = 0; // ---- HEADER BAR ------------------------------------------------------- setFill(doc, C.blueDark); doc.rect(0, 0, PW, 42, 'F'); // Company name doc.setFont('helvetica', 'bold'); doc.setFontSize(20); setTextColor(doc, C.white); doc.text('LynkedUpPro Roofing', ML, 14); // Tagline doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(180, 200, 255); doc.text('Professional Roofing Estimates | Plano, TX', ML, 20); // ESTIMATE label (right side) doc.setFont('helvetica', 'bold'); doc.setFontSize(26); setTextColor(doc, C.white); doc.text('ESTIMATE', PW - MR, 16, { align: 'right' }); // Estimate meta — right aligned doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(200, 220, 255); doc.text(`Date: ${estimate.date}`, PW - MR, 23, { align: 'right' }); doc.text(`ID: ${estimate.id}`, PW - MR, 28, { align: 'right' }); doc.text(`Status: ${estimate.status}`, PW - MR, 33, { align: 'right' }); // Status dot const statusColors = { Approved: C.greenDark, Sent: C.blue, Draft: C.zinc500, Rejected: [220, 38, 38], Expired: [217, 119, 6], }; setFill(doc, statusColors[estimate.status] ?? C.zinc500); doc.circle(PW - MR - doc.getTextWidth(`Status: ${estimate.status}`) - 3, 32.2, 1.5, 'F'); y = 50; // ---- CLIENT INFO CARD ------------------------------------------------- setFill(doc, C.blueLight); setDraw(doc, C.blueMid); doc.setLineWidth(0.3); doc.roundedRect(ML, y, CW, 28, 3, 3, 'FD'); // Blue left accent bar setFill(doc, C.blue); doc.roundedRect(ML, y, 2.5, 28, 1, 1, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(7.5); setTextColor(doc, C.blue); doc.text('CLIENT INFORMATION', ML + 6, y + 6); doc.setFont('helvetica', 'bold'); doc.setFontSize(13); setTextColor(doc, C.zinc900); doc.text(estimate.clientName, ML + 6, y + 13); doc.setFont('helvetica', 'normal'); doc.setFontSize(9); setTextColor(doc, C.zinc500); doc.text(estimate.address, ML + 6, y + 19.5); const metaItems = [ estimate.estimatedBy && `Estimated by: ${estimate.estimatedBy}`, estimate.roofArea && `Roof area: ${estimate.roofArea} SQ`, estimate.templateName && `Template: ${estimate.templateName}`, ].filter(Boolean); doc.setFontSize(8); setTextColor(doc, C.zinc400); doc.text(metaItems.join(' · '), ML + 6, y + 25); y += 35; // ---- NOTES ------------------------------------------------------------ if (estimate.notes) { setFill(doc, C.amberLight); setDraw(doc, C.amberBorder); doc.setLineWidth(0.3); const noteLines = doc.splitTextToSize(`Note: ${estimate.notes}`, CW - 12); const noteH = noteLines.length * 5 + 8; doc.roundedRect(ML, y, CW, noteH, 2, 2, 'FD'); doc.setFont('helvetica', 'italic'); doc.setFontSize(8); setTextColor(doc, C.amber800); doc.text(noteLines, ML + 5, y + 6); y += noteH + 6; } // ---- SCOPE OF WORK ---------------------------------------------------- if (template?.description?.length) { // Section heading doc.setFont('helvetica', 'bold'); doc.setFontSize(9); setTextColor(doc, C.blueDark); doc.text('SCOPE OF WORK', ML, y + 5); setFill(doc, C.blue); doc.rect(ML, y + 7, CW, 0.6, 'F'); y += 12; template.description.forEach((line) => { const wrapped = doc.splitTextToSize(line, CW - 8); // Bullet dot setFill(doc, C.blue); doc.circle(ML + 2, y + 1.5, 1, 'F'); doc.setFont('helvetica', 'normal'); doc.setFontSize(8.5); setTextColor(doc, C.zinc700); doc.text(wrapped, ML + 6, y + 3); y += wrapped.length * 5 + 2; }); y += 4; } // ---- MATERIALS TABLE -------------------------------------------------- if (materials.length) { doc.setFont('helvetica', 'bold'); doc.setFontSize(9); setTextColor(doc, C.blueDark); doc.text('MATERIALS', ML, y + 5); setFill(doc, C.blue); doc.rect(ML, y + 7, CW, 0.6, 'F'); y += 10; autoTable(doc, { startY: y, margin: { left: ML, right: MR }, head: [['Description', 'Qty', 'UOM', 'Unit Price', 'Total']], body: materials.map(m => [ m.desc, m.qty, m.uom, `$${(m.clientPrice / m.qty).toFixed(2)}`, `$${m.clientPrice.toLocaleString('en-US', { minimumFractionDigits: 2 })}` ]), foot: [[ { content: 'Materials Subtotal', colSpan: 4, styles: { halign: 'right', fontStyle: 'bold' } }, { content: `$${materials.reduce((s, m) => s + m.clientPrice, 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, styles: { fontStyle: 'bold' } } ]], headStyles: { fillColor: C.blue, textColor: C.white, fontStyle: 'bold', fontSize: 8, }, footStyles: { fillColor: C.blueMid, textColor: C.blueDark, fontSize: 8.5, }, bodyStyles: { fontSize: 8, textColor: C.zinc700 }, alternateRowStyles: { fillColor: C.blueLight }, columnStyles: { 0: { cellWidth: 'auto' }, 1: { halign: 'right', cellWidth: 18 }, 2: { halign: 'center', cellWidth: 18 }, 3: { halign: 'right', cellWidth: 25 }, 4: { halign: 'right', cellWidth: 32 }, }, showFoot: 'lastPage', tableLineColor: C.blueMid, tableLineWidth: 0.2, }); y = doc.lastAutoTable.finalY + 8; } // ---- LABOR TABLE ------------------------------------------------------ if (labor.length) { // Page break check if (y > PH - 60) { doc.addPage(); y = 20; } doc.setFont('helvetica', 'bold'); doc.setFontSize(9); setTextColor(doc, C.purpleDark); doc.text('LABOR', ML, y + 5); setFill(doc, C.purple); doc.rect(ML, y + 7, CW, 0.6, 'F'); y += 10; autoTable(doc, { startY: y, margin: { left: ML, right: MR }, head: [['Description', 'Qty', 'UOM', 'Unit Price', 'Total']], body: labor.map(l => [ l.desc, l.qty, l.uom, `$${(l.clientPrice / l.qty).toFixed(2)}`, `$${l.clientPrice.toLocaleString('en-US', { minimumFractionDigits: 2 })}` ]), foot: [[ { content: 'Labor Subtotal', colSpan: 4, styles: { halign: 'right', fontStyle: 'bold' } }, { content: `$${labor.reduce((s, l) => s + l.clientPrice, 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, styles: { fontStyle: 'bold' } } ]], headStyles: { fillColor: C.purple, textColor: C.white, fontStyle: 'bold', fontSize: 8, }, footStyles: { fillColor: C.purpleMid, textColor: C.purpleDark, fontSize: 8.5, }, bodyStyles: { fontSize: 8, textColor: C.zinc700 }, alternateRowStyles: { fillColor: C.purpleLight }, columnStyles: { 0: { cellWidth: 'auto' }, 1: { halign: 'right', cellWidth: 18 }, 2: { halign: 'center', cellWidth: 18 }, 3: { halign: 'right', cellWidth: 25 }, 4: { halign: 'right', cellWidth: 32 }, }, showFoot: 'lastPage', tableLineColor: C.purpleMid, tableLineWidth: 0.2, }); y = doc.lastAutoTable.finalY + 8; } // ---- FINANCIAL SUMMARY ------------------------------------------------ if (y > PH - 55) { doc.addPage(); y = 20; } const matTotal = materials.reduce((s, m) => s + m.clientPrice, 0); const laborTotal = labor.reduce((s, l) => s + l.clientPrice, 0); const grandTotal = matTotal + laborTotal; const summaryX = PW - MR - 85; const summaryW = 85; let sy = y; // Section label doc.setFont('helvetica', 'bold'); doc.setFontSize(9); setTextColor(doc, C.zinc900); doc.text('FINANCIAL SUMMARY', ML, sy + 5); setFill(doc, C.zinc200); doc.rect(ML, sy + 7, CW, 0.4, 'F'); sy += 12; // Summary box setFill(doc, C.zinc100); setDraw(doc, C.zinc200); doc.setLineWidth(0.3); doc.roundedRect(summaryX, sy, summaryW, 36, 2, 2, 'FD'); const rowH = 8; const rows = [ materials.length ? ['Materials', matTotal] : null, labor.length ? ['Labor', laborTotal] : null, ].filter(Boolean); rows.forEach(([label, val], i) => { doc.setFont('helvetica', 'normal'); doc.setFontSize(8.5); setTextColor(doc, C.zinc500); doc.text(label, summaryX + 5, sy + 8 + i * rowH); setTextColor(doc, C.zinc700); doc.text(`$${val.toLocaleString('en-US', { minimumFractionDigits: 2 })}`, summaryX + summaryW - 5, sy + 8 + i * rowH, { align: 'right' }); }); // Divider const divY = sy + rows.length * rowH + 4; setDraw(doc, C.zinc200); doc.setLineWidth(0.4); doc.line(summaryX + 4, divY, summaryX + summaryW - 4, divY); // Grand total row setFill(doc, C.greenLight); doc.roundedRect(summaryX, divY + 1, summaryW, 12, 0, 0, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(10); setTextColor(doc, C.greenDark); doc.text('TOTAL', summaryX + 5, divY + 9); doc.text(`$${grandTotal.toLocaleString('en-US', { minimumFractionDigits: 2 })}`, summaryX + summaryW - 5, divY + 9, { align: 'right' }); y = Math.max(y + 55, divY + 25); // ---- FOOTER ----------------------------------------------------------- const footerY = PH - 18; setFill(doc, C.zinc100); doc.rect(0, footerY - 2, PW, 20, 'F'); setDraw(doc, C.zinc200); doc.setLineWidth(0.3); doc.line(0, footerY - 2, PW, footerY - 2); doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); setTextColor(doc, C.zinc500); doc.text('LynkedUpPro Roofing | Plano, TX | lynkeduppro.com', ML, footerY + 4); doc.text('This estimate is valid for 30 days from the date of issue.', ML, footerY + 9); // Page numbers const pageCount = doc.internal.getNumberOfPages(); for (let i = 1; i <= pageCount; i++) { doc.setPage(i); doc.setFontSize(7.5); setTextColor(doc, C.zinc400); doc.text(`Page ${i} of ${pageCount}`, PW - MR, footerY + 4, { align: 'right' }); } // ---- SAVE ------------------------------------------------------------- const safeName = estimate.clientName.replace(/\s+/g, '_'); doc.save(`Estimate_${safeName}_${estimate.date}.pdf`); }