Files
LynkedUpPro_CRM/src/utils/estimateExport.js
T
Satyam-Rastogi fe9ddda941 feat(estimates): Estimates hub with templates, detail drawer, status tracker, sort, and share
- Add EstimatesPage with Estimates + Templates tabs, KPI strip, search, and filter chips
- Estimate detail drawer with pipeline status tracker (Draft→Sent→Waiting Approval→Approved) and edge statuses (Follow Up Required, Revision Required, Rejected, Expired); clickable nodes update estimate status live
- Share bar: WhatsApp, iMessage, Email, PDF download (jsPDF + autotable, light-mode A4)
- ALL_STATUS_CONFIG as shared status registry across cards, badge, filters, and tracker
- Sort controls: Newest First / Oldest First / By Status
- Date range filter with From/To date inputs and clear button
- Fix unit price discrepancy: Unit Price = clientPrice/qty so Qty × Unit Price = Total
- Fix chatbot overlap: pr-20 on drawer footer keeps grand total visible
- Fix card vs drawer total mismatch: computeGrandTotal extracted to estimateExport.js, used as single source of truth in cards, drawer, PDF, and WhatsApp message
- TemplateEditorModal: Owner CRUD for master templates, duplicate with Save as Copy / Replace Original toggle
2026-04-01 16:45:16 +05:30

550 lines
19 KiB
JavaScript

/**
* 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`);
}