Sales Proposal Generator
${escapeHTML(proposal.timeline).replace(/\n/g, '
')}
Investment
| Item | Cost |
${proposal.pricing.map(item => `
| ${escapeHTML(item.name)} |
$${parseFloat(item.value).toLocaleString()} |
`).join('')}
| Total Investment |
$${proposal.pricing.reduce((sum, item) => sum + parseFloat(item.value), 0).toLocaleString()} |
Terms & Conditions
${escapeHTML(proposal.terms).replace(/\n/g, '
')}
`;
proposalContent.innerHTML = html;
// 2. Render KPIs
const totalValue = proposal.pricing.reduce((sum, item) => sum + parseFloat(item.value), 0);
kpiTotal.textContent = `$${totalValue.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
// 3. Render Chart
renderCostChart();
// 4. Show buttons
pdfDownloadBtn.style.display = 'inline-block';
copyBtn.style.display = 'inline-block';
}
function renderCostChart() {
if (!costChartCanvas) return;
const ctx = costChartCanvas.getContext('2d');
const labels = proposal.pricing.map(item => item.name);
const data = proposal.pricing.map(item => item.value);
if (costChartInstance) {
costChartInstance.destroy();
}
if (data.length === 0) {
ctx.clearRect(0, 0, costChartCanvas.width, costChartCanvas.height);
ctx.font = "14px Inter";
ctx.fillStyle = "#a8a29e"; // stone-400
ctx.textAlign = "center";
ctx.fillText("No pricing items to display chart.", costChartCanvas.width / 2, costChartCanvas.height / 2);
return;
}
costChartInstance = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
label: 'Cost Breakdown',
data: data,
backgroundColor: [
'#f97316', // orange-500
'#fb923c', // orange-400
'#fdba74', // orange-300
'#78716c', // stone-500
'#a8a29e', // stone-400
],
borderColor: '#ffffff',
borderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false, // Per spec
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: (context) => `${context.label}: $${parseFloat(context.raw).toLocaleString()}`
}
}
}
}
});
}
// --- Event Handlers ---
function handleGenerateProposal() {
// Save data from form to proposal object
proposal.clientName = clientNameInput.value;
proposal.clientCompany = clientCompanyInput.value;
proposal.date = dateInput.value;
proposal.overview = overviewInput.value;
proposal.timeline = timelineInput.value;
proposal.terms = termsInput.value;
// Check for required fields
if (!proposal.clientName || !proposal.clientCompany || !proposal.date) {
showMessage("Please fill in Client Name, Company, and Date.", "error");
return;
}
renderDashboard();
showTab(0);
}
function handleAddDeliverable() {
const text = newDeliverableInput.value.trim();
if (text) {
proposal.deliverables.push(text);
newDeliverableInput.value = '';
renderBuilderLists();
}
}
function handleAddPriceItem() {
const name = newPriceItemInput.value.trim();
const value = parseFloat(newPriceValueInput.value);
if (name && value > 0) {
proposal.pricing.push({ name, value });
newPriceItemInput.value = '';
newPriceValueInput.value = '';
renderBuilderLists();
}
}
function handleBuilderListDelete(e) {
const target = e.target;
if (!target.classList.contains('delete-btn')) return;
const type = target.dataset.type;
const index = parseInt(target.dataset.index, 10);
if (type === 'deliverable') proposal.deliverables.splice(index, 1);
if (type === 'pricing') proposal.pricing.splice(index, 1);
renderBuilderLists();
}
function handleConfigDelete(e) {
const target = e.target;
if (!target.classList.contains('delete-btn')) return;
const type = target.dataset.type;
const id = target.dataset.id;
if (type === 'reusableItem') deleteConfigItem('reusableItems', id);
if (type === 'reusableTerm') deleteConfigItem('reusableTerms', id);
}
async function downloadPDF() {
pdfTarget.classList.add('spg-pdf-view');
try {
// Ensure chart is rendered for PDF
const chartCanvasForPdf = document.createElement('canvas');
chartCanvasForPdf.width = costChartCanvas.width;
chartCanvasForPdf.height = costChartCanvas.height;
const chartCtxPdf = chartCanvasForPdf.getContext('2d');
chartCtxPdf.fillStyle = '#FFFFFF';
chartCtxPdf.fillRect(0, 0, chartCanvasForPdf.width, chartCanvasForPdf.height);
if (costChartInstance) {
chartCtxPdf.drawImage(costChartCanvas, 0, 0);
} else {
chartCtxPdf.font = "14px Inter";
chartCtxPdf.fillStyle = "#a8a29e";
chartCtxPdf.textAlign = "center";
chartCtxPdf.fillText("No pricing items.", chartCanvasForPdf.width / 2, chartCanvasForPdf.height / 2);
}
const chartImage = chartCanvasForPdf.toDataURL('image/png');
const canvas = await html2canvas(pdfTarget, {
scale: 2, logging: false, useCORS: true,
ignoreElements: (element) => element.id === 'spg-cost-chart' // Ignore live chart
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('p', 'mm', 'a4');
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const margin = 15;
const imgWidth = pdfWidth - (margin * 2);
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let yPosition = margin;
pdf.addImage(imgData, 'PNG', margin, yPosition, imgWidth, imgHeight);
yPosition += imgHeight + 10;
// Add Chart
const chartHeightInPdf = (chartCanvasForPdf.height * imgWidth) / chartCanvasForPdf.width;
if (yPosition + chartHeightInPdf > pdfHeight - margin) {
pdf.addPage();
yPosition = margin;
}
pdf.setFontSize(14);
pdf.setTextColor(55, 65, 81);
pdf.text("Cost Breakdown", margin, yPosition);
yPosition += 8;
pdf.addImage(chartImage, 'PNG', margin, yPosition, imgWidth, chartHeightInPdf);
pdf.save(`${proposal.clientCompany || 'Sales_Proposal'}.pdf`);
} catch (error) {
console.error("Error generating PDF:", error);
showMessage("Error generating PDF. Please try again.", "error");
} finally {
pdfTarget.classList.remove('spg-pdf-view');
}
}
function copyProposalText() {
let text = `Sales Proposal for: ${proposal.clientCompany}\n`;
text += `Prepared for: ${proposal.clientName}\nDate: ${proposal.date}\n\n`;
text += `--- PROJECT OVERVIEW ---\n${proposal.overview}\n\n`;
text += `--- SCOPE & DELIVERABLES ---\n${proposal.deliverables.map(item => `• ${item}`).join('\n')}\n\n`;
text += `--- PROJECT TIMELINE ---\n${proposal.timeline}\n\n`;
text += `--- INVESTMENT ---\n`;
let total = 0;
proposal.pricing.forEach(item => {
text += `• ${item.name}: $${parseFloat(item.value).toLocaleString()}\n`;
total += parseFloat(item.value);
});
text += `\nTotal: $${total.toLocaleString()}\n\n`;
text += `--- TERMS & CONDITIONS ---\n${proposal.terms}`;
const el = document.createElement('textarea');
el.value = text;
document.body.appendChild(el);
el.select();
try {
document.execCommand('copy');
showMessage('Proposal text copied to clipboard!');
} catch (err) {
showMessage('Failed to copy text.', 'error');
}
document.body.removeChild(el);
}
// --- Utilities ---
function showMessage(message, type = "info") {
let feedbackEl = document.getElementById('feedback-message');
if (!feedbackEl) {
feedbackEl = document.createElement('div');
feedbackEl.id = 'feedback-message';
feedbackEl.style.position = 'fixed';
feedbackEl.style.bottom = '20px';
feedbackEl.style.left = '50%';
feedbackEl.style.transform = 'translateX(-50%)';
feedbackEl.style.padding = '10px 20px';
feedbackEl.style.borderRadius = '8px';
feedbackEl.style.zIndex = '1000';
feedbackEl.style.opacity = '0';
feedbackEl.style.transition = 'opacity 0.5s ease';
document.body.appendChild(feedbackEl);
}
feedbackEl.textContent = message;
feedbackEl.style.opacity = '1';
const colors = { error: { bg: '#fef2f2', text: '#991b1b' }, success: { bg: '#f0fdf4', text: '#14532d' }, info: { bg: '#eff6ff', text: '#1e3a8a' }};
feedbackEl.style.backgroundColor = colors[type].bg;
feedbackEl.style.color = colors[type].text;
setTimeout(() => { feedbackEl.style.opacity = '0'; }, 3000);
}
function escapeHTML(str) {
if (typeof str !== 'string') return '';
return str ? str.replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])) : '';
}
function loadSampleData() {
// Set default date
dateInput.value = new Date().toISOString().split('T')[0];
// Sample data (USA-centric)
addConfigItem('reusableItems', { name: "Website Design (5 Pages)", value: 5000 });
addConfigItem('reusableItems', { name: "E-commerce Setup (Shopify, USA)", value: 7000 });
addConfigItem('reusableTerms', { text: "Payment due 30 days upon receipt of invoice. Overdue invoices subject to 1.5% monthly interest." });
addConfigItem('reusableTerms', { text: "All work is property of [Your Company] until final payment is received." });
}
// --- Event Listeners ---
navTabs.forEach((tab, index) => tab.addEventListener('click', () => showTab(index)));
navPrev.addEventListener('click', () => showTab(currentTab - 1));
navNext.addEventListener('click', () => showTab(currentTab + 1));
// Tab 1
pdfDownloadBtn.addEventListener('click', downloadPDF);
copyBtn.addEventListener('click', copyProposalText);
// Tab 2
generateBtn.addEventListener('click', handleGenerateProposal);
addDeliverableBtn.addEventListener('click', handleAddDeliverable);
addPriceItemBtn.addEventListener('click', handleAddPriceItem);
deliverablesList.addEventListener('click', handleBuilderListDelete);
pricingList.addEventListener('click', handleBuilderListDelete);
addSavedItemBtn.addEventListener('click', () => {
if (dbState.reusableItems.length > 0) {
proposal.pricing.push(...dbState.reusableItems);
renderBuilderLists();
} else { showMessage("No saved items to add.", "info"); }
});
addSavedTermBtn.addEventListener('click', () => {
if (dbState.reusableTerms.length > 0) {
const allTerms = dbState.reusableTerms.map(t => t.text).join('\n\n');
termsInput.value = (termsInput.value + '\n\n' + allTerms).trim();
} else { showMessage("No saved terms to add.", "info"); }
});
// Tab 3
addReusableItemForm.addEventListener('submit', (e) => {
e.preventDefault();
addConfigItem('reusableItems', { name: newReusableItemName.value, value: parseFloat(newReusableItemValue.value) });
addReusableItemForm.reset();
});
addReusableTermForm.addEventListener('submit', (e) => {
e.preventDefault();
addConfigItem('reusableTerms', { text: newReusableTermText.value });
addReusableTermForm.reset();
});
reusableItemsList.addEventListener('click', handleConfigDelete);
reusableTermsList.addEventListener('click', handleConfigDelete);
// --- Initial Load ---
loadSampleData();
renderAll();
showTab(1); // Start on builder
Generated Proposal
Configuration
Save reusable pricing items and terms clauses to your database. These will be available to add in the 'Proposal Builder' tab.
Saved Terms Clauses