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
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
Reference in New Issue
Block a user