Files
svsmspcalc/quote-export.js
2026-03-15 18:24:36 -04:00

343 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function(global) {
'use strict';
function fmt(n) {
return '$' + Math.round(n).toLocaleString('en-US');
}
const PRINT_LOGO = `<svg height="30" viewBox="0 0 424.27 97.38" xmlns="http://www.w3.org/2000/svg"><polyline points="7.32 8.88 62.11 8.88 34.72 58.22" fill="#1f75a6"/><polyline points="40.7 55.33 64.4 12.64 71.88 12.64 44.48 61.99 40.7 55.33" fill="#8d252f"/><path d="M110.03,89.91c-5.99,0-11.27-.66-15.86-1.99-4.59-1.33-8.6-3.2-12.05-5.63-3.45-2.42-6.54-5.27-9.27-8.53l14.21-15.92c3.79,4.85,7.75,8.05,11.88,9.61,4.13,1.55,8.01,2.33,11.65,2.33,1.44,0,2.73-.13,3.87-.4,1.14-.26,2.01-.7,2.62-1.31.61-.61.91-1.44.91-2.5,0-.98-.32-1.82-.97-2.5-.65-.68-1.5-1.27-2.56-1.76-1.06-.49-2.22-.91-3.47-1.25-1.25-.34-2.46-.62-3.64-.85-1.18-.23-2.22-.46-3.13-.68-4.55-1.06-8.53-2.35-11.94-3.87-3.41-1.51-6.25-3.33-8.53-5.46-2.27-2.12-3.96-4.55-5.06-7.28-1.1-2.73-1.65-5.8-1.65-9.21,0-3.87.89-7.39,2.67-10.57,1.78-3.18,4.17-5.91,7.16-8.19,2.99-2.27,6.4-4.02,10.23-5.23,3.83-1.21,7.79-1.82,11.88-1.82,5.99,0,10.99.55,15.01,1.65,4.02,1.1,7.39,2.67,10.12,4.72,2.73,2.05,5.08,4.43,7.05,7.16l-14.32,13.76c-1.67-1.59-3.41-2.9-5.23-3.92-1.82-1.02-3.7-1.78-5.63-2.27-1.93-.49-3.85-.74-5.74-.74-1.74,0-3.22.13-4.43.4-1.21.27-2.16.68-2.84,1.25-.68.57-1.02,1.35-1.02,2.33s.43,1.8,1.31,2.44c.87.64,1.97,1.19,3.3,1.65,1.32.46,2.65.82,3.98,1.08,1.33.27,2.44.47,3.35.62,4.17.76,8,1.8,11.48,3.13,3.49,1.33,6.54,3,9.15,5,2.61,2.01,4.62,4.51,6.03,7.5,1.4,3,2.1,6.54,2.1,10.63,0,5.84-1.46,10.73-4.38,14.67-2.92,3.94-6.84,6.92-11.77,8.92-4.93,2.01-10.42,3.01-16.48,3.01Z" fill="#1a1816"/><path d="M174.27,88.77l-31.72-79.58h24.56l12.73,34.79c.91,2.35,1.65,4.36,2.22,6.03.57,1.67,1.08,3.24,1.54,4.72.46,1.48.89,3.05,1.31,4.72.42,1.67.89,3.71,1.42,6.14h-3.98c.76-3.18,1.42-5.8,1.99-7.84.57-2.05,1.21-4.07,1.93-6.08.72-2.01,1.65-4.56,2.79-7.67l12.73-34.79h23.76l-31.95,79.58h-19.33Z" fill="#1a1816"/><path d="M257.94,89.91c-5.99,0-11.27-.66-15.86-1.99-4.59-1.33-8.6-3.2-12.05-5.63-3.45-2.42-6.54-5.27-9.27-8.53l14.21-15.92c3.79,4.85,7.75,8.05,11.88,9.61,4.13,1.55,8.02,2.33,11.65,2.33,1.44,0,2.73-.13,3.87-.4,1.14-.26,2.01-.7,2.62-1.31.61-.61.91-1.44.91-2.5,0-.98-.32-1.82-.97-2.5-.64-.68-1.5-1.27-2.56-1.76-1.06-.49-2.22-.91-3.47-1.25-1.25-.34-2.46-.62-3.64-.85-1.18-.23-2.22-.46-3.13-.68-4.55-1.06-8.53-2.35-11.94-3.87-3.41-1.51-6.25-3.33-8.53-5.46-2.27-2.12-3.96-4.55-5.06-7.28-1.1-2.73-1.65-5.8-1.65-9.21,0-3.87.89-7.39,2.67-10.57,1.78-3.18,4.17-5.91,7.16-8.19,2.99-2.27,6.4-4.02,10.23-5.23,3.83-1.21,7.79-1.82,11.88-1.82,5.99,0,10.99.55,15.01,1.65,4.02,1.1,7.39,2.67,10.12,4.72,2.73,2.05,5.08,4.43,7.05,7.16l-14.33,13.76c-1.67-1.59-3.41-2.9-5.23-3.92-1.82-1.02-3.69-1.78-5.63-2.27-1.93-.49-3.85-.74-5.74-.74-1.74,0-3.22.13-4.43.4-1.21.27-2.16.68-2.84,1.25-.68.57-1.02,1.35-1.02,2.33s.44,1.8,1.31,2.44c.87.64,1.97,1.19,3.3,1.65,1.33.46,2.65.82,3.98,1.08,1.33.27,2.44.47,3.35.62,4.17.76,8,1.8,11.48,3.13,3.49,1.33,6.54,3,9.15,5,2.62,2.01,4.62,4.51,6.03,7.5,1.4,3,2.1,6.54,2.1,10.63,0,5.84-1.46,10.73-4.38,14.67-2.92,3.94-6.84,6.92-11.77,8.92-4.93,2.01-10.42,3.01-16.48,3.01Z" fill="#1a1816"/><path d="M300.67,47.36V8.02h10.45l14.33,23.33-8.49-.06,14.5-23.27h10.12v39.35h-11.69v-9.39c0-3.37.08-6.41.25-9.11.17-2.7.46-5.38.87-8.04l1.35,3.54-9.5,14.73h-3.71l-9.33-14.73,1.41-3.54c.41,2.51.7,5.09.87,7.73.17,2.64.25,5.78.25,9.42v9.39h-11.69Z" fill="#1a1816"/><path d="M364.36,47.93c-2.96,0-5.57-.33-7.84-.98-2.27-.66-4.25-1.58-5.96-2.78-1.71-1.2-3.23-2.6-4.58-4.22l7.03-7.87c1.87,2.4,3.83,3.98,5.87,4.75,2.04.77,3.96,1.15,5.76,1.15.71,0,1.35-.07,1.91-.2.56-.13.99-.35,1.29-.65.3-.3.45-.71.45-1.24,0-.49-.16-.9-.48-1.24s-.74-.63-1.26-.87c-.53-.24-1.1-.45-1.71-.62-.62-.17-1.22-.31-1.8-.42-.58-.11-1.1-.22-1.55-.34-2.25-.52-4.22-1.16-5.9-1.91-1.69-.75-3.09-1.65-4.22-2.7-1.12-1.05-1.96-2.25-2.5-3.6-.54-1.35-.81-2.87-.81-4.55,0-1.91.44-3.65,1.32-5.23.88-1.57,2.06-2.92,3.54-4.05s3.17-1.99,5.06-2.58,3.85-.9,5.87-.9c2.96,0,5.43.27,7.42.81,1.99.54,3.65,1.32,5,2.33,1.35,1.01,2.51,2.19,3.49,3.54l-7.08,6.8c-.83-.79-1.69-1.43-2.59-1.94s-1.83-.88-2.78-1.12c-.96-.24-1.9-.37-2.84-.37-.86,0-1.59.07-2.19.2-.6.13-1.07.34-1.41.62-.34.28-.51.67-.51,1.15s.21.89.65,1.21c.43.32.97.59,1.63.82.66.22,1.31.4,1.97.53.66.13,1.21.23,1.66.31,2.06.38,3.95.89,5.68,1.55,1.72.66,3.23,1.48,4.53,2.47s2.29,2.23,2.98,3.71c.69,1.48,1.04,3.23,1.04,5.26,0,2.89-.72,5.3-2.16,7.25-1.44,1.95-3.38,3.42-5.82,4.41-2.44.99-5.15,1.49-8.15,1.49Z" fill="#1a1816"/><path d="M386.61,47.36V8.02h17.71c2.7,0,5.1.58,7.2,1.74,2.1,1.16,3.75,2.75,4.95,4.78,1.2,2.02,1.8,4.35,1.8,6.97s-.6,5.17-1.8,7.31c-1.2,2.14-2.85,3.81-4.95,5.03-2.1,1.22-4.5,1.83-7.2,1.83h-5.56v11.69h-12.14ZM398.53,25.33h3.54c.71,0,1.35-.14,1.91-.42.56-.28,1-.68,1.32-1.21.32-.52.48-1.18.48-1.97s-.16-1.42-.48-1.91c-.32-.49-.76-.85-1.32-1.1-.56-.24-1.2-.37-1.91-.37h-3.54v6.97Z" fill="#1a1816"/></svg>`;
function printInvoice() {
const pricing = global.getPricingConfig();
var state = global.readFormState();
const q = global.SVSQuoteEngine.calculateQuote(state, pricing);
const waived = document.getElementById('onboardingWaived')?.checked || false;
const feeEl = document.getElementById('oneTimeFee');
const onboardingFee = waived ? 0 : (parseFloat(feeEl?.value) || Math.round(q.MRR / 2));
const waivedAmt = Math.round(q.MRR / 2);
const quoteRef = document.getElementById('quoteRef')?.textContent || '—';
const quoteDate = document.getElementById('headerDate')?.textContent || '—';
const client = q.clientName || 'Client';
const repName = document.getElementById('repName')?.value || '';
const quoteNotes = document.getElementById('quoteNotes')?.value || '';
const termLabel = q.contractTerm === '12mo' ? '12-Month Contract — 3% off MRR'
: q.contractTerm === '24mo' ? '24-Month Contract — 5% off MRR'
: 'Month-to-Month';
// P2: Compute explicit validity date (30 days from now)
const now = new Date();
const validUntil = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const validUntilStr = validUntil.toLocaleDateString('en-CA', { year: 'numeric', month: 'long', day: 'numeric' });
const rows = [];
const row = (label, detail, amt, sub) => rows.push({ label, detail, amt, sub: !!sub });
if (q.users > 0) {
const pkg = q.byol ? 'BYOL — Bring Your Own License' : 'M365 Premium Included (Identity, Email & Protection)';
row(`User Package — ${pkg}`, `${q.users} user${q.users !== 1 ? 's' : ''} × ${fmt(q.baseUserRate)}/mo`, fmt(q.userBase));
if (q.userExt > 0) row(`↳ Extended Hours (+${fmt(pricing.ADDON_EXT_HOURS)}/user)`, '', fmt(q.userExt), true);
if (q.userPWM > 0) row(`↳ 1Password Business (+${fmt(pricing.ADDON_1PASSWORD)}/user)`, '', fmt(q.userPWM), true);
if (q.userINKY > 0) row(`↳ INKY Pro Upgrade (+${fmt(pricing.ADDON_INKY)}/user)`, '', fmt(q.userINKY), true);
if (q.userZT > 0) row(`↳ Zero Trust User (+${fmt(pricing.ADDON_ZERO_TRUST_USER)}/user)`, '', fmt(q.userZT), true);
}
if (q.endpoints > 0) {
row('Endpoint Management', `${q.endpoints} endpoint${q.endpoints !== 1 ? 's' : ''} × ${fmt(pricing.RATE_ENDPOINT)}/mo`, fmt(q.endpointBase));
if (q.endpointUSB > 0) row(`↳ USB Blocking (+${fmt(pricing.ADDON_USB_BLOCKING)}/endpoint)`, '', fmt(q.endpointUSB), true);
if (q.endpointBMB > 0) row(`↳ Bare Metal Backup (+${fmt(pricing.ADDON_BARE_METAL_BACKUP)}/endpoint)`, '', fmt(q.endpointBMB), true);
}
if (q.servers > 0) {
row('Server Management', `${q.servers} server${q.servers !== 1 ? 's' : ''} × ${fmt(pricing.RATE_SERVER)}/mo`, fmt(q.serverBase));
}
if (q.ztNetTotal > 0) {
row('Zero Trust Networking — HaaS', '', fmt(q.ztNetTotal));
if (q.ztNetSeats > 0) row(`↳ ZT Seats (${q.ztSeats} × ${fmt(pricing.ZT_SEAT_RATE)}/mo)`, '', fmt(q.ztNetSeats), true);
if (q.ztNetRouters > 0) row(`↳ HaaS Routers (${q.ztRouters} × ${fmt(pricing.ZT_ROUTER_RATE)}/mo)`, '', fmt(q.ztNetRouters), true);
}
if (q.voipTotal > 0) {
const tier = { basic: 'Basic', standard: 'Standard', premium: 'Premium' }[q.voipTier] || 'Basic';
row(`VoIP / UCaaS — ${tier}`, `${q.voipSeats} seat${q.voipSeats !== 1 ? 's' : ''} × $${q.voipSeatRate}/mo`, fmt(q.voipSeatsAmt));
if (q.voipPhoneAmt > 0) row(`↳ Desk Phone HaaS (+${fmt(pricing.VOIP_PHONE_RATE)}/seat)`, '', fmt(q.voipPhoneAmt), true);
if (q.voipFaxAmt > 0) row(`↳ Virtual Fax (+${fmt(pricing.VOIP_FAX_RATE)}/mo)`, '', fmt(q.voipFaxAmt), true);
}
if (q.adminWaived) {
row('Site Admin Fee', `Tenant, network, documentation & vendor management (waived; normally ${fmt(q.adminFeeNet)}/mo)`, fmt(0));
} else {
row('Site Admin Fee', 'Tenant, network, documentation & vendor management', fmt(q.adminFeeNet));
}
row('↳ Base Site Admin', '', fmt(q.siteAdminBase), true);
if (q.ztActive) row('↳ Zero Trust Supplement', '', fmt(pricing.ADMIN_FEE_ZT), true);
if (q.addPWM && q.admin1PWM > 0) {
row(`↳ 1Password Management (${Math.round(pricing.ADMIN_1PWM_PCT * 100)}%)`, '', fmt(q.admin1PWM), true);
}
const itemsHTML = rows.map(r => `
<tr${r.sub ? ' class="sub"' : ''}>
<td class="lbl">${r.label}</td>
<td class="det">${r.detail}</td>
<td class="amt">${r.amt}/mo</td>
</tr>`).join('');
const features = [];
const feat = (name, active, detail) => features.push({ name, active, detail: detail || '' });
feat('Licensing Model', true, q.byol ? 'BYOL — Bring Your Own License' : 'M365 Premium Included');
feat('Extended Help Desk Hours', q.addExtHours, q.addExtHours ? `+${fmt(pricing.ADDON_EXT_HOURS)}/user/mo` : '');
feat('1Password Business', q.addPWM, q.addPWM ? `+${fmt(pricing.ADDON_1PASSWORD)}/user/mo` : '');
feat('INKY Pro Upgrade', q.addINKY, q.addINKY ? `+${fmt(pricing.ADDON_INKY)}/user/mo` : '');
feat('Zero Trust User Access', q.addZT, q.addZT ? `+${fmt(pricing.ADDON_ZERO_TRUST_USER)}/user/mo` : '');
feat('USB Device Blocking', q.addUSB, q.addUSB ? `+${fmt(pricing.ADDON_USB_BLOCKING)}/endpoint/mo` : '');
feat('Bare Metal Backup', q.addBMB, q.addBMB ? `+${fmt(pricing.ADDON_BARE_METAL_BACKUP)}/endpoint/mo` : '');
feat('Zero Trust Networking (HaaS)', q.ztNetTotal > 0, q.ztNetTotal > 0 ? `${q.ztSeats} seats, ${q.ztRouters} routers` : '');
feat('VoIP / UCaaS', q.voipTotal > 0, q.voipTotal > 0 ? `${({ basic: 'Basic', standard: 'Standard', premium: 'Premium' })[q.voipTier]} — ${q.voipSeats} seats` : '');
feat('Desk Phone HaaS', q.addVoipPhone, q.addVoipPhone ? `+${fmt(pricing.VOIP_PHONE_RATE)}/seat/mo` : '');
feat('Virtual Fax', q.addVoipFax, q.addVoipFax ? `+${fmt(pricing.VOIP_FAX_RATE)}/mo` : '');
// P5: Split into included vs excluded (CYA)
const included = features.filter(f => f.active);
const excluded = features.filter(f => !f.active);
const includedHTML = included.map(f => `
<div class="cfg-item">
<span class="cfg-icon">&#10003;</span>
<span class="cfg-name">${f.name}</span>
${f.detail ? `<span class="cfg-detail">${f.detail}</span>` : ''}
</div>`).join('');
const excludedHTML = excluded.length ? `
<div class="sec-lbl sec-lbl--excluded">Services Not Included in This Quote</div>
<div class="cfg-grid cfg-grid--excluded">${excluded.map(f => `
<div class="cfg-item cfg-inactive">
<span class="cfg-icon">&#10005;</span>
<span class="cfg-name">${f.name}</span>
</div>`).join('')}
</div>` : '';
let totals = '';
if (q.discountPct > 0) {
totals += `<tr class="t-muted"><td colspan="2">Base MRR</td><td>${fmt(q.MRR)}/mo</td></tr>`;
totals += `<tr class="t-muted"><td colspan="2">Term Discount (${Math.round(q.discountPct * 100)}% off)</td><td>${fmt(q.discountAmt)}/mo</td></tr>`;
}
totals += `<tr class="t-mrr"><td colspan="2">Monthly Recurring (MRR)</td><td>${fmt(q.effectiveMrr)}/mo</td></tr>`;
if (q.users > 0) {
totals += `<tr class="t-muted"><td colspan="2">Per-User Effective Cost</td><td>${fmt(q.effectiveMrr / q.users)}/user/mo</td></tr>`;
totals += `<tr class="t-muted"><td colspan="2" style="padding-left:20px;font-size:11px">${fmt(q.perUserServices)} user services + ${fmt(q.perUserSiteOvhd)} site overhead</td><td></td></tr>`;
}
if (q.hstEnabled) {
totals += `<tr class="t-muted"><td colspan="2">Ontario HST (${Math.round(pricing.HST_RATE * 100)}%)</td><td>+${fmt(q.hstAmt)}/mo</td></tr>`;
totals += `<tr class="t-total"><td colspan="2">Total Monthly</td><td>${fmt(q.mrrWithHst)}/mo</td></tr>`;
}
if (waived && waivedAmt > 0) {
totals += `<tr class="t-waived"><td colspan="2">Onboarding Fee <span class="badge">COMPLIMENTARY</span> — included with ${termLabel.split(' —')[0]}</td><td>${fmt(waivedAmt)} saved</td></tr>`;
} else if (q.contractTerm === '12mo' && onboardingFee > 0) {
totals += `<tr class="t-waived"><td colspan="2">Onboarding Fee <span class="badge">50% OFF</span> — 12-Month term</td><td><s>${fmt(waivedAmt)}</s> ${fmt(onboardingFee)}</td></tr>`;
} else if (onboardingFee > 0) {
totals += `<tr class="t-muted"><td colspan="2">Onboarding Fee (one-time)</td><td>${fmt(onboardingFee)}</td></tr>`;
}
totals += `<tr class="t-annual"><td colspan="2">Annual Projection</td><td>${fmt(q.effectiveAnnual)}/yr</td></tr>`;
// P1: Notes section (only if notes exist)
const notesHTML = quoteNotes.trim() ? `
<div class="notes-section">
<div class="sec-lbl">Notes</div>
<div class="notes-body">${quoteNotes.trim().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>')}</div>
</div>` : '';
// P4: Rep name in header
const repHTML = repName.trim() ? `<div class="rep-line">Prepared by: ${repName.trim().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>` : '';
const html = `<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8">
<title>SVS MSP Quote — ${client}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&family=DM+Mono:wght@400;500&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Poppins',Arial,sans-serif;font-size:13px;color:#1a1816;background:#fff;padding:52px 56px;max-width:860px;margin:0 auto}
.hdr{display:flex;justify-content:space-between;align-items:flex-start;padding-bottom:24px;border-bottom:2.5px solid #1a6a98;margin-bottom:32px}
.hdr-meta{text-align:right;font-family:'DM Mono',monospace;font-size:10.5px;color:#6b6360;letter-spacing:.07em;line-height:1.8}
.hdr-meta .ref{font-size:13px;font-weight:500;color:#1a1816;letter-spacing:.09em}
.client-lbl{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:#6b6360;margin-bottom:4px}
.client-name{font-size:24px;font-weight:700;color:#1a1816;margin-bottom:6px}
.client-meta{font-family:'DM Mono',monospace;font-size:11px;color:#6b6360}
.rep-line{font-family:'DM Mono',monospace;font-size:11px;color:#6b6360;margin-top:4px}
.sec-lbl{font-family:'DM Mono',monospace;font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:#6b6360;margin:28px 0 8px;padding-bottom:6px;border-bottom:1px solid #e8e4db}
.items{width:100%;border-collapse:collapse;page-break-inside:auto}
.items tr{page-break-inside:avoid;break-inside:avoid}
.items td{padding:9px 8px;vertical-align:top;border-bottom:1px solid #f0ede8}
.items .lbl{color:#1a1816;width:50%}
.items .det{color:#6b6360;font-family:'DM Mono',monospace;font-size:11px;width:30%}
.items .amt{text-align:right;font-family:'DM Mono',monospace;font-size:12px;color:#1a6a98;width:20%;white-space:nowrap}
.items tr.sub td{padding-top:3px;padding-bottom:3px;font-size:12px;color:#6b6360}
.items tr.sub .amt{color:#8a9aaa}
.tots-wrap{page-break-inside:avoid;break-inside:avoid}
.tots{width:100%;border-collapse:collapse;margin-top:4px}
.tots td{padding:8px 8px;border-bottom:1px solid #f0ede8;vertical-align:middle}
.tots td:last-child{text-align:right;font-family:'DM Mono',monospace;white-space:nowrap}
.t-muted td{color:#6b6360;font-size:12px}
.t-mrr td{font-weight:600;font-size:15px}
.t-mrr td:last-child{color:#1a6a98;font-size:16px}
.t-total td{font-weight:700;background:#f4f2ed;font-size:15px}
.t-total td:last-child{font-size:17px;color:#1a1816}
.t-waived td{color:#217045}
.t-waived td:last-child{font-family:'DM Mono',monospace}
.t-annual td{color:#6b6360;font-size:12px;border-bottom:none}
.badge{display:inline-block;background:#e8f7ef;color:#217045;font-size:9px;font-family:'DM Mono',monospace;letter-spacing:.1em;padding:1px 6px;border-radius:3px;margin-left:6px;vertical-align:middle}
.cfg-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 24px;margin-bottom:8px}
.cfg-item{display:flex;align-items:baseline;gap:8px;font-size:11.5px;padding:4px 0}
.cfg-icon{font-size:11px;font-weight:700;color:#217045;flex-shrink:0}
.cfg-inactive .cfg-icon{color:#c0392b;font-size:10px;font-weight:400}
.cfg-inactive .cfg-name{color:#aaa;text-decoration:line-through;font-size:10.5px}
.cfg-name{color:#1a1816}
.cfg-detail{font-family:'DM Mono',monospace;font-size:10px;color:#6b6360;margin-left:auto}
.cfg-grid--excluded{gap:3px 24px;margin-bottom:4px}
.sec-lbl--excluded{margin-top:16px;font-size:9px;color:#aaa;border-bottom-color:#f0ede8}
.notes-section{page-break-inside:avoid;break-inside:avoid}
.notes-body{font-size:12px;color:#4a4744;line-height:1.7;padding:6px 0}
.footer{margin-top:48px;padding-top:18px;border-top:1px solid #e8e4db;display:flex;justify-content:space-between;align-items:flex-end}
.footer-left{font-family:'DM Mono',monospace;font-size:10px;color:#aaa;letter-spacing:.05em;line-height:1.9}
.footer-note{font-size:11px;color:#999;margin-top:8px;max-width:480px;line-height:1.6}
@media print{body{padding:0}@page{margin:1.5cm;size:A4}}
</style></head><body>
<div class="hdr">
${PRINT_LOGO}
<div class="hdr-meta">
<div class="ref">${quoteRef}</div>
<div>${quoteDate}</div>
<div style="margin-top:4px;text-transform:uppercase;letter-spacing:.1em">MSP Service Proposal</div>
</div>
</div>
<div class="client-lbl">Prepared for</div>
<div class="client-name">${client}</div>
<div class="client-meta">${termLabel} &nbsp;·&nbsp; ${q.hstEnabled ? 'HST included in figures' : 'Prices in CAD, excl. HST'}</div>
${repHTML}
<div class="sec-lbl">Service Breakdown</div>
<table class="items"><tbody>${itemsHTML}</tbody></table>
<div class="sec-lbl">Your Service Configuration</div>
<div class="cfg-grid">${includedHTML}</div>
${excludedHTML}
<div class="sec-lbl" style="page-break-before:auto">Quote Summary</div>
<div class="tots-wrap">
<table class="tots"><tbody>${totals}</tbody></table>
</div>
${notesHTML}
<div class="footer">
<div>
<div class="footer-left">SILICON VALLEY SERVICES &nbsp;·&nbsp; OTTAWA, ON${repName.trim() ? ` &nbsp;·&nbsp; ${repName.trim().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}` : ''}</div>
<div class="footer-note">This quote is valid until <strong>${validUntilStr}</strong>. All prices in CAD. HST at ${Math.round(pricing.HST_RATE * 100)}% applies on invoice unless already included. MRR is billed monthly in advance.</div>
</div>
<div style="font-family:'DM Mono',monospace;font-size:10px;color:#bbb;letter-spacing:.06em">${quoteDate}</div>
</div>
<script>window.addEventListener('load',()=>{window.print();})<\/script>
</body></html>`;
const w = window.open('', '_blank', 'width=900,height=700');
if (w) {
w.document.write(html);
w.document.close();
}
}
function exportQuoteJSON() {
const q = global.calcQuote();
const ref = document.getElementById('quoteRef')?.textContent || '';
const dt = new Date();
const dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
const termMap = { m2m: 'Month-to-Month', '12mo': '12-Month', '24mo': '24-Month' };
const slugify = (value, fallback) => {
const slug = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || fallback;
};
const payload = {
version: '1.1',
quoteRef: ref,
quoteDate: dateStr,
clientName: q.clientName || '',
repName: document.getElementById('repName')?.value || '',
quoteNotes: document.getElementById('quoteNotes')?.value || '',
contractTerm: termMap[q.contractTerm] || 'Month-to-Month',
licensing: q.byol ? 'BYOL' : 'M365-Included',
users: q.users,
endpoints: q.endpoints,
servers: q.servers,
addons: {
extendedHours: q.addExtHours,
passwordManager: q.addPWM,
inkyPro: q.addINKY,
zeroTrustUsers: q.addZT,
baremetalBackup: q.addBMB,
usbBlocking: q.addUSB
},
zeroTrustNetwork: {
seats: q.ztSeats,
routers: q.ztRouters
},
voip: {
tier: q.voipSeats > 0 ? q.voipTier : null,
seats: q.voipSeats,
phoneHardware: q.addVoipPhone,
faxLine: q.addVoipFax,
currentPhoneBill: parseFloat(document.getElementById('currentPhoneBill')?.value) || 0
},
adminWaived: q.adminWaived || false,
onboardingWaived: document.getElementById('onboardingWaived')?.checked || false,
onboardingManual: document.getElementById('oneTimeFee')?.dataset.manual === '1',
pricing: {
baseMrr: q.MRR,
discountPct: Math.round(q.discountPct * 100),
discountAmt: q.discountAmt,
effectiveMrr: q.effectiveMrr,
annual: q.effectiveAnnual,
oneTimeFee: q.oneTimeFee,
hstIncluded: q.hstEnabled,
hstAmt: q.hstAmt,
mrrWithHst: q.mrrWithHst
}
};
const json = JSON.stringify(payload, null, 2);
const clientSlug = slugify(q.clientName, 'client');
const refSlug = slugify(ref, 'quote');
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${clientSlug}-${dateStr}-${refSlug}.json`;
a.click();
URL.revokeObjectURL(url);
const btn = document.getElementById('btnExportJSON');
if (btn) {
const orig = btn.innerHTML;
btn.innerHTML = '&#10003; Quote Saved';
setTimeout(() => { btn.innerHTML = orig; }, 2000);
}
}
global.SVSQuoteExport = {
printInvoice,
exportQuoteJSON
};
global.printInvoice = printInvoice;
global.exportQuoteJSON = exportQuoteJSON;
})(window);