343 lines
22 KiB
JavaScript
343 lines
22 KiB
JavaScript
(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">✓</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">✕</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,'&').replace(/</g,'<').replace(/>/g,'>').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,'&').replace(/</g,'<').replace(/>/g,'>')}</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} · ${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 · OTTAWA, ON${repName.trim() ? ` · ${repName.trim().replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}` : ''}</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 = '✓ Quote Saved';
|
||
setTimeout(() => { btn.innerHTML = orig; }, 2000);
|
||
}
|
||
}
|
||
|
||
global.SVSQuoteExport = {
|
||
printInvoice,
|
||
exportQuoteJSON
|
||
};
|
||
|
||
global.printInvoice = printInvoice;
|
||
global.exportQuoteJSON = exportQuoteJSON;
|
||
})(window);
|