Files
svsmspcalc/SVS-MSP-Calculator.js
2026-03-15 18:24:36 -04:00

394 lines
16 KiB
JavaScript

// Pricing defaults and JSON loading live in quote-pricing.js.
// This file intentionally consumes the pricing globals exposed there
// so Phase 1 can stay low-risk and preserve the current runtime API.
// Rendering helpers and nudge state live in quote-render.js.
// Persistence, export/print, theme, and mobile sync live in dedicated
// modules so this file can stay focused on quote calculation orchestration.
// --- CALC ---
// ── calcQuote() ─────────────────────────────────────────────────
// Compatibility wrapper around the pure Phase 2 quote engine.
// Reads live form state from the DOM, then delegates pricing math to
// calculateQuote(state, pricing) without changing the rest of the app.
function calcQuote() {
return calculateQuote(readFormState(), getPricingConfig());
}
// --- MAIN UPDATE ---
// ── update() ─────────────────────────────────────────────────────
// Master DOM update function. Called on every input change.
// Sequence: calcQuote → update all displays → renderNudge →
// updateSavings → updateVsComparison → updateSectionSummaries
// Also wrapped by the mobile IIFE below to sync _m elements.
// Do not call renderNudge() directly from other functions;
// always go through update() to keep _m panel in sync.
function update() {
const q = calcQuote();
const pricing = getPricingConfig();
const bestM365Rate = Math.max(pricing.RATE_M365_M2M || 0, pricing.RATE_M365 || 0);
const m365BundleSavings = Math.max(0, bestM365Rate - pricing.RATE_BYOL);
const render = window.SVSQuoteRender;
// ── Onboarding fee logic ──
// m2m: auto = 50% MRR, manual override allowed, waive toggle available
// 12-month: auto = 50% off onboarding (25% of MRR), manual override allowed
// 24-month: complimentary (fully waived), input disabled
const waivedEl = document.getElementById('onboardingWaived');
const feeEl = document.getElementById('oneTimeFee');
const fullOnboarding = Math.round(q.MRR / 2);
if (waivedEl && q.contractTerm === '24mo') {
// 24-month: fully complimentary
waivedEl.checked = true;
waivedEl.disabled = true;
waivedEl.dataset.autoWaived = '1';
// Preserve any manual override so it survives the round-trip back to m2m/12mo
if (feeEl && feeEl.dataset.manual) {
feeEl.dataset.manualValue = feeEl.value;
delete feeEl.dataset.manual;
}
} else if (waivedEl && q.contractTerm === '12mo') {
// 12-month: 50% off onboarding, not waived — clear any auto-waive state
waivedEl.disabled = false;
if (waivedEl.dataset.autoWaived) {
waivedEl.checked = false;
delete waivedEl.dataset.autoWaived;
}
// Restore manual override if user had one before switching to 24mo
if (feeEl && !feeEl.dataset.manual && feeEl.dataset.manualValue) {
feeEl.dataset.manual = '1';
feeEl.value = feeEl.dataset.manualValue;
delete feeEl.dataset.manualValue;
}
} else if (waivedEl) {
// m2m: full manual control
waivedEl.disabled = false;
if (waivedEl.dataset.autoWaived) {
waivedEl.checked = false;
delete waivedEl.dataset.autoWaived;
}
// Restore manual override if user had one before switching to 24mo
if (feeEl && !feeEl.dataset.manual && feeEl.dataset.manualValue) {
feeEl.dataset.manual = '1';
feeEl.value = feeEl.dataset.manualValue;
delete feeEl.dataset.manualValue;
}
}
const waived = waivedEl?.checked || false;
let oneTimeFee;
if (waived) {
oneTimeFee = 0;
if (feeEl) { feeEl.value = ''; feeEl.disabled = true; feeEl.placeholder = 'Complimentary'; }
} else if (q.contractTerm === '12mo') {
// 12-month: 50% off the standard onboarding fee
if (feeEl) { feeEl.disabled = false; feeEl.placeholder = '50% off'; }
if (feeEl && !feeEl.dataset.manual) {
oneTimeFee = Math.round(fullOnboarding / 2);
feeEl.value = oneTimeFee > 0 ? oneTimeFee : '';
} else {
oneTimeFee = parseFloat(feeEl?.value) || 0;
}
} else {
// m2m: standard auto-calc
if (feeEl) { feeEl.disabled = false; feeEl.placeholder = 'auto'; }
if (feeEl && !feeEl.dataset.manual) {
oneTimeFee = fullOnboarding;
feeEl.value = oneTimeFee > 0 ? oneTimeFee : '';
} else {
oneTimeFee = parseFloat(feeEl?.value) || 0;
}
}
q.oneTimeFee = oneTimeFee;
const renderOptions = {
m365BundleSavings,
oneTimeFee,
onboardingWaived: waived,
onboardingWouldBe: fullOnboarding,
onboardingHalfOff: q.contractTerm === '12mo' && !waived
};
render.renderQuoteUi(q, renderOptions);
render.renderSidebar(q, renderOptions);
render.setNudges(render.buildNudges(q, renderOptions));
renderNudge();
updateSavings(q);
updateVsComparison(q);
updateSectionSummaries(q);
debouncedSave();
}
// ── onWaiveToggle() ──────────────────────────────────────────────
// Called from onchange on #onboardingWaived checkbox.
// Clears the manual override flag on the fee input so auto-calc resumes,
// then runs update(). Extracted from inline HTML attribute for clarity.
function onWaiveToggle() {
const feeInput = document.getElementById('oneTimeFee');
if (feeInput) feeInput.removeAttribute('data-manual');
update();
}
// ── toggleSection(id) ────────────────────────────────────────────
// Collapses/expands a numbered section card.
// Adds/removes .sec-open on the section element.
// .sec-open → chevron rotates 180deg (CSS), body shown (JS display).
// Calls updateSectionSummaries() to show/hide summary badges.
// Map: section ID → collapsible IDs that should auto-expand when section opens
const _sectionCollapsibles = {};
function finishSectionAnimation(body, isOpen) {
body.style.transition = '';
body.style.overflow = '';
body.style.height = '';
body.style.opacity = '';
body.style.display = isOpen ? '' : 'none';
}
function animateSectionBody(body, open) {
if (!body) return;
if (body._sectionAnimationCleanup) {
body._sectionAnimationCleanup();
body._sectionAnimationCleanup = null;
}
body.style.overflow = 'hidden';
body.style.transition = 'height 0.34s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.22s ease';
if (open) {
body.style.display = '';
body.style.height = '0px';
body.style.opacity = '0';
body.getBoundingClientRect();
const targetHeight = body.scrollHeight;
requestAnimationFrame(() => {
body.style.height = targetHeight + 'px';
body.style.opacity = '1';
});
} else {
const startHeight = body.scrollHeight || body.offsetHeight;
body.style.display = '';
body.style.height = startHeight + 'px';
body.style.opacity = '1';
body.getBoundingClientRect();
requestAnimationFrame(() => {
body.style.height = '0px';
body.style.opacity = '0';
});
}
const onEnd = (event) => {
if (event.target !== body || event.propertyName !== 'height') return;
body.removeEventListener('transitionend', onEnd);
body._sectionAnimationCleanup = null;
finishSectionAnimation(body, open);
};
body._sectionAnimationCleanup = () => {
body.removeEventListener('transitionend', onEnd);
finishSectionAnimation(body, open);
};
body.addEventListener('transitionend', onEnd);
}
function toggleSection(id) {
const section = document.getElementById(id);
const body = document.getElementById(id + '-body');
if (!section || !body) return;
const isOpen = section.classList.toggle('sec-open');
// Sync aria-expanded on the section-toggle header
const header = section.querySelector('.section-toggle');
if (header) header.setAttribute('aria-expanded', String(isOpen));
animateSectionBody(body, isOpen);
// Auto-expand inner collapsibles when section opens
if (isOpen && _sectionCollapsibles[id]) {
_sectionCollapsibles[id].forEach(cid => {
const cBody = document.getElementById(cid);
const cIcon = document.getElementById(cid + '-icon');
const cPreview = document.getElementById(cid + '-preview');
if (cBody && !cBody.classList.contains('open')) {
cBody.classList.add('open');
if (cIcon) cIcon.classList.add('open');
if (cPreview) cPreview.style.display = 'none';
}
});
}
updateSectionSummaries(calcQuote());
updateToggleAllBtn();
}
// ── toggleAllSections() / updateToggleAllBtn() ────────────────────
// Collapse all if any are open; expand all if all are closed.
// Button label reflects current state.
const _allSecIds = ['sec-02','sec-03','sec-01','sec-04','sec-05','sec-06'];
function toggleAllSections() {
const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open'));
_allSecIds.forEach(id => {
const section = document.getElementById(id);
const body = document.getElementById(id + '-body');
if (!section || !body) return;
if (anyOpen) {
section.classList.remove('sec-open');
animateSectionBody(body, false);
}
else {
section.classList.add('sec-open');
animateSectionBody(body, true);
// Auto-expand inner collapsibles
if (_sectionCollapsibles[id]) {
_sectionCollapsibles[id].forEach(cid => {
const cBody = document.getElementById(cid);
const cIcon = document.getElementById(cid + '-icon');
const cPreview = document.getElementById(cid + '-preview');
if (cBody && !cBody.classList.contains('open')) {
cBody.classList.add('open');
if (cIcon) cIcon.classList.add('open');
if (cPreview) cPreview.style.display = 'none';
}
});
}
}
});
updateSectionSummaries(calcQuote());
updateToggleAllBtn();
}
function updateToggleAllBtn() {
const anyOpen = _allSecIds.some(id => document.getElementById(id)?.classList.contains('sec-open'));
const btn = document.getElementById('toggleAllBtn');
if (!btn) return;
const collapseSpan = btn.querySelector('.toggle-all-collapse-icon');
const expandSpan = btn.querySelector('.toggle-all-expand-icon');
const textSpan = btn.querySelector('.toggle-all-label');
if (collapseSpan) collapseSpan.style.display = anyOpen ? '' : 'none';
if (expandSpan) expandSpan.style.display = anyOpen ? 'none' : '';
if (textSpan) textSpan.textContent = anyOpen ? 'Collapse All' : 'Expand All';
}
// ── toggleCollapsible(id) ─────────────────────────────────────────
// Collapses/expands inner content panels (What's Included, Add-Ons).
// Separate from section-level toggle. Toggles .open on .collapsible-body.
// Also toggles preview pills (shown when collapsed, hidden when open).
function toggleCollapsible(id) {
const body = document.getElementById(id);
const icon = document.getElementById(id + '-icon');
const preview = document.getElementById(id + '-preview');
if (!body) return;
const open = body.classList.toggle('open');
if (icon) icon.classList.toggle('open', open);
if (preview) preview.style.display = open ? 'none' : 'flex';
// Sync aria-expanded on the collapsible header that controls this body
const header = body.previousElementSibling;
if (header && header.classList.contains('collapsible-header')) {
header.setAttribute('aria-expanded', String(open));
}
}
// ── toggleAddon(checkId, rowId) ─────────────────────────────────
// Flips the hidden checkbox + toggles .selected on the visible row.
// Called via onclick on the <label class="addon-row"> element.
// The label's onclick fires BEFORE the native checkbox change,
// so we manually invert cb.checked here instead of reading it.
function toggleAddon(checkId, rowId) {
const cb = document.getElementById(checkId);
const row = document.getElementById(rowId);
cb.checked = !cb.checked;
if (row) row.classList.toggle('selected', cb.checked);
}
// ── activateTier(tier) ───────────────────────────────────────────
// Sets the active VoIP tier pill (basic/standard/premium).
// Adds .active class to matching .tier-seg, removes from others.
// Called from onclick on tier labels (alongside update()).
function activateTier(tier) {
const el = document.getElementById('voip' + tier.charAt(0).toUpperCase() + tier.slice(1));
if (el) { el.checked = true; update(); }
}
// Rendering helpers for summaries, savings, nudges, and the VS comparison
// now live in quote-render.js. They stay available globally so the existing
// HTML shell and inline handlers do not change.
// ── HELPERS ──────────────────────────────────────────────────────
// stepInput — increment/decrement a num-input by delta, respects min
function stepInput(id, delta) {
const el = document.getElementById(id);
if (!el) return;
const min = parseInt(el.min) ?? 0;
el.value = Math.max(min, (parseInt(el.value) || 0) + delta);
update();
}
// Persistence and reset behavior now live in quote-persistence.js and stay
// global so update(), initQuote(), and inline HTML handlers continue to work.
// Export/print and theme management live in their own Phase 4 modules and
// remain available globally for the existing buttons and init flow.
// ── initQuote() ──────────────────────────────────────────────────
// Entry point. Sets quote ref (SVS-YYYYMMDD-XXXX) and date,
// restores saved state, then calls update() for initial render.
// Called once at bottom of script.
function syncAddonPriceLabels() {
document.querySelectorAll('.addon-price[data-price-key]').forEach(function(el) {
var key = el.dataset.priceKey;
var unit = el.dataset.priceUnit || 'unit';
var val = window[key];
if (typeof val === 'number') {
el.textContent = '+$' + val + '/' + unit + '/mo';
}
});
}
async function initQuote() {
await loadPricing();
syncAddonPriceLabels();
const now = new Date();
const months = ['JANUARY','FEBRUARY','MARCH','APRIL','MAY','JUNE','JULY','AUGUST','SEPTEMBER','OCTOBER','NOVEMBER','DECEMBER'];
const year = now.getFullYear();
const month = months[now.getMonth()];
const quoteRef = getOrCreateQuoteRef(now);
const quoteRefEl = document.getElementById('quoteRef');
if (quoteRefEl) quoteRefEl.textContent = quoteRef;
const headerDateEl = document.getElementById('headerDate');
if (headerDateEl) headerDateEl.textContent = `${month} ${year}`;
restoreState();
update();
updateToggleAllBtn();
}
// ── Keyboard Shortcuts ───────────────────────────────────────────
// Ctrl+P → Print invoice (overrides browser print)
// Ctrl+E → Export JSON
// Ctrl+R → Reset quote (opens confirm modal)
// Escape → Close sidebar focus / mobile panel (handled in mobile-sync.js)
document.addEventListener('keydown', function(e) {
// Skip when user is typing in an input/textarea
var tag = (document.activeElement || {}).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.ctrlKey && !e.shiftKey && !e.altKey) {
if (e.key === 'p' || e.key === 'P') {
e.preventDefault();
if (typeof printInvoice === 'function') printInvoice();
} else if (e.key === 'e' || e.key === 'E') {
e.preventDefault();
if (typeof exportQuoteJSON === 'function') exportQuoteJSON();
} else if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
if (typeof openResetConfirm === 'function') openResetConfirm();
}
}
});
initTheme();
initQuote();
// Mobile sidebar cloning and sync now live in mobile-sync.js, which loads
// after this file so it can wrap the finalized global update() function.