/** * QuickBooks Migration Wizard — qb-migration-wizard.js * Embedded in Setup Wizard Step 2. * Manages a 9-step modal that walks the user through all QB Desktop data imports. */ (function () { 'use strict'; // ── Step Definitions ────────────────────────────────────────────────────── const STEPS = [ { id: 1, key: 'chartOfAccounts', title: 'Chart of Accounts', icon: 'bi-diagram-3', fileAccept: '.iif', endpoint: '/Tools/ImportChartOfAccounts', deps: [], intro: 'Your Chart of Accounts defines the financial structure of your books. Import this first so vendor bills and other financial data can link to the correct accounts.', instructions: [ 'In QuickBooks Desktop, go to Lists → Chart of Accounts.', 'Click the Account button at the bottom of the list.', 'Choose Import/Export → Export Chart of Accounts (or use File → Utilities → Export → Lists to IIF Files → Chart of Accounts).', 'Save the .iif file and upload it here.' ] }, { id: 2, key: 'customers', title: 'Customers', icon: 'bi-people', fileAccept: '.iif', endpoint: '/Tools/ImportCustomers', deps: [], intro: 'Import your customer list so that historical invoices and payments can be matched to existing customer records.', instructions: [ 'In QuickBooks Desktop, go to File → Utilities → Export → Lists to IIF Files.', 'Check Customer List and click OK.', 'Save the .iif file and upload it here.' ] }, { id: 3, key: 'vendors', title: 'Vendors', icon: 'bi-truck', fileAccept: '.iif', endpoint: '/Tools/ImportVendors', deps: [], intro: 'Import your vendor list. Vendors are required before importing inventory stock levels and vendor bills.', instructions: [ 'In QuickBooks Desktop, go to File → Utilities → Export → Lists to IIF Files.', 'Check Vendor List and click OK.', 'Save the .iif file and upload it here.' ] }, { id: 4, key: 'catalogItems', title: 'Catalog Items', icon: 'bi-grid', fileAccept: '.iif', endpoint: '/Tools/ImportCatalogItems', deps: [], intro: 'Import service items from QuickBooks to populate your catalog with pre-priced services.', instructions: [ 'In QuickBooks Desktop, go to File → Utilities → Export → Lists to IIF Files.', 'Check Item List and click OK.', 'Save the .iif file and upload it here.' ] }, { id: 5, key: 'inventory', title: 'Inventory', icon: 'bi-boxes', fileAccept: '.csv', endpoint: '/Tools/ImportQbInventoryValuation', deps: [3], intro: 'Import current stock levels from an Inventory Valuation Summary report. Vendors must be imported first for preferred vendor matching.', instructions: [ 'In QuickBooks Desktop, go to Reports → Inventory → Inventory Valuation Summary.', 'Click Customize Report, open the Display tab, and add the Preferred Vendor column.', 'Click Excel → Create New Worksheet → Comma Separated Values (.csv) and save.', 'Upload the CSV file here.' ] }, { id: 6, key: 'invoices', title: 'Invoices', icon: 'bi-receipt', fileAccept: '.csv', endpoint: '/Tools/ImportQbInvoices', deps: [2, 4], intro: 'Import historical invoices from a Customer Balance Detail report. Customers and catalog items should be imported first.', instructions: [ 'In QuickBooks Desktop, go to Reports → Customers & Receivables → Customer Balance Detail.', 'Set the date range to All to cover your full history.', 'Click Excel → Create New Worksheet → Comma Separated Values (.csv) and save.', 'Upload the CSV file here.' ] }, { id: 7, key: 'transactions', title: 'Customer Payments', icon: 'bi-cash-coin', fileAccept: '.csv', endpoint: '/Tools/ImportQbTransactions', deps: [6], intro: 'Import payment records to update invoice balances. Invoices must be imported first.', instructions: [ 'In QuickBooks Desktop, go to Reports → Customers & Receivables → Transaction List by Customer.', 'Set the date range to All to cover your full history.', 'Click Excel → Create New Worksheet → Comma Separated Values (.csv) and save.', 'Upload the CSV file here.' ] }, { id: 8, key: 'bills', title: 'Vendor Bills & Payments', icon: 'bi-file-earmark-text', fileAccept: '.csv', endpoint: '/Tools/ImportQbBillsAndPayments', deps: [1, 3], intro: 'Import vendor bills and payment history in a single step. The same Vendor Balance Detail file contains both — bills are imported first, then payments are matched against them.', instructions: [ 'In QuickBooks Desktop, go to Reports → Vendors & Payables → Vendor Balance Detail.', 'Set the date range to All to cover your full history.', 'Click Excel → Create New Worksheet → Comma Separated Values (.csv) and save.', 'Upload the CSV file here — bills and payments will both be processed automatically.' ] } ]; // ── QuickBooks Online Step Definitions ──────────────────────────────────── const QBO_STEPS = [ { id: 1, key: 'qbo_coa', title: 'Chart of Accounts', icon: 'bi-diagram-3', fileAccept: '.xlsx,.xls', endpoint: '/Tools/ImportQboChartOfAccounts', deps: [], intro: 'Your Chart of Accounts defines the financial structure of your books. Import this first so financial data can link to the correct accounts.', instructions: [ 'In QuickBooks Online, go to Accounting → Chart of Accounts.', 'Click the Run Report button at the top right.', 'Click the Export icon (spreadsheet icon) and choose Export to Excel.', 'Save the .xlsx file and upload it here.' ] }, { id: 2, key: 'qbo_customers', title: 'Customers', icon: 'bi-people', fileAccept: '.xlsx,.xls', endpoint: '/Tools/ImportQboCustomers', deps: [], intro: 'Import your customer list so invoices and payments can be matched to existing customer records.', instructions: [ 'In QuickBooks Online, go to Reports and search for Customer Contact List.', 'Click Customize if you want to include additional fields (address, terms, balance).', 'Click the Export icon and choose Export to Excel.', 'Save the .xlsx file and upload it here.' ] }, { id: 3, key: 'qbo_vendors', title: 'Vendors', icon: 'bi-truck', fileAccept: '.xlsx,.xls', endpoint: '/Tools/ImportQboVendors', deps: [], intro: 'Import your vendor list before importing purchase orders or inventory.', instructions: [ 'In QuickBooks Online, go to Reports and search for Vendor Contact List.', 'Click the Export icon and choose Export to Excel.', 'Save the .xlsx file and upload it here.' ] }, { id: 4, key: 'qbo_products', title: 'Products & Services', icon: 'bi-grid', fileAccept: '.xlsx,.xls', endpoint: '/Tools/ImportQboCatalogItems', deps: [], intro: 'Import your products and services. Inventory-type items will create inventory records; all others become catalog items.', instructions: [ 'In QuickBooks Online, go to Sales → Products and Services.', 'Click the More (⋮) button at the top right.', 'Choose Export to Excel.', 'Save the .xlsx file and upload it here.' ] }, { id: 5, key: 'qbo_invoices', title: 'Invoices', icon: 'bi-receipt', fileAccept: '.xlsx,.xls', endpoint: '/Tools/ImportQboInvoices', deps: [2], intro: 'Import historical invoices. Customers should be imported first for matching.', instructions: [ 'In QuickBooks Online, go to Reports and search for Invoice List.', 'Set the date range to cover all of your history.', 'Click the Export icon and choose Export to Excel.', 'Save the .xlsx file and upload it here.' ] }, { id: 6, key: 'qbo_transactions', title: 'Payments', icon: 'bi-cash-stack', fileAccept: '.xlsx,.xls', endpoint: '/Tools/ImportQboTransactions', deps: [5], intro: 'Import payment history to mark invoices as paid. Invoices must be imported first.', instructions: [ 'In QuickBooks Online, go to Reports and search for Transaction List by Date.', 'Set the date range to cover all of your history.', 'Click the Export icon and choose Export to Excel.', 'Save the .xlsx file and upload it here.' ] } ]; // ── Active step set (Desktop or Online) ─────────────────────────────────── let activeSteps = STEPS; // default to Desktop let activeSource = 'desktop'; // Statuses: pending | complete | skipped | error | blocked let currentStepIdx = 0; // index into activeSteps let stepState = {}; // key -> { status, result } // ── Init ────────────────────────────────────────────────────────────────── function init() { // Default all steps to pending activeSteps.forEach(s => { stepState[s.key] = stepState[s.key] || { status: 'pending', result: null }; }); refreshBlocked(); } function refreshBlocked() { activeSteps.forEach(s => { if (s.deps.length === 0) return; const current = stepState[s.key].status; if (current === 'complete' || current === 'skipped') return; const anyDepUnresolved = s.deps.some(depId => { const depStep = activeSteps.find(x => x.id === depId); const depStatus = depStep ? stepState[depStep.key].status : 'pending'; return depStatus === 'pending' || depStatus === 'blocked' || depStatus === 'error'; }); stepState[s.key].status = anyDepUnresolved ? 'blocked' : 'pending'; }); } // ── Open Modal ──────────────────────────────────────────────────────────── window.openQbWizard = async function (source) { activeSteps = (source === 'online') ? QBO_STEPS : STEPS; activeSource = source; stepState = {}; // reset state when switching source init(); // Load persisted state from server try { const resp = await fetch('/SetupWizard/GetQbMigrationState'); const data = await resp.json(); if (data.state) { const saved = JSON.parse(data.state); // Only restore state if same source if (saved && saved.source === source && saved.steps) { Object.assign(stepState, saved.steps); } // Resume at first incomplete step const firstIncomplete = activeSteps.findIndex(s => { const st = stepState[s.key]?.status; return st !== 'complete' && st !== 'skipped'; }); currentStepIdx = firstIncomplete >= 0 ? firstIncomplete : activeSteps.length - 1; } } catch (e) { // Non-fatal — start from beginning } refreshBlocked(); // Update modal title to reflect source var titleEl = document.getElementById('qbMigrationWizardLabel'); if (titleEl) { titleEl.textContent = source === 'online' ? 'QuickBooks Online Migration Wizard' : 'QuickBooks Desktop Migration Wizard'; } renderWizard(); const modal = new bootstrap.Modal(document.getElementById('qbMigrationWizard')); modal.show(); }; // ── Render ──────────────────────────────────────────────────────────────── function renderWizard() { renderStepIndicator(); renderProgressBar(); renderStepContent(); renderFooterButtons(); } function renderStepIndicator() { const container = document.getElementById('qbwStepIndicator'); if (!container) return; container.innerHTML = activeSteps.map((s, idx) => { const st = stepState[s.key]?.status || 'pending'; const isActive = idx === currentStepIdx; let statusClass = isActive ? 'active' : st; let icon = ''; if (st === 'complete') icon = ''; else if (st === 'skipped') icon = ''; else if (st === 'error') icon = ''; else if (st === 'blocked') icon = ''; else icon = s.id; return `
${icon}
${s.title}
`; }).join(''); // Update badge const badge = document.getElementById('qbwStepBadge'); if (badge) badge.textContent = `Step ${currentStepIdx + 1} of ${activeSteps.length}`; } function renderProgressBar() { const completed = Object.values(stepState).filter(s => s.status === 'complete' || s.status === 'skipped').length; const pct = Math.round((completed / activeSteps.length) * 100); const bar = document.getElementById('qbwProgressBar'); if (bar) bar.style.width = pct + '%'; } function renderStepContent() { const container = document.getElementById('qbwStepContent'); if (!container) return; const step = activeSteps[currentStepIdx]; const st = stepState[step.key]; container.innerHTML = buildStepHtml(step, st); } function buildStepHtml(step, st) { const isBlocked = st.status === 'blocked'; const blockedNames = step.deps.map(depId => { const depStep = activeSteps.find(x => x.id === depId); return depStep ? depStep.title : ''; }).filter(Boolean).join(', '); let resultHtml = ''; if (st.result) { resultHtml = buildResultHtml(st.result, st.status); } const instructionItems = step.instructions.map(i => `
  • ${i}
  • `).join(''); return `
    ${step.title}

    ${step.intro}

    ${isBlocked ? `
    Step locked. This step requires the following to be completed first: ${blockedNames}. Go back and complete those steps, then return here.
    ` : ''}

      ${instructionItems}
    ${!isBlocked ? `
    Importing…
    ` : ''} ${resultHtml ? `
    ${resultHtml}
    ` : ''}
    `; } function buildResultHtml(result, status) { const alertClass = status === 'error' ? 'alert-danger' : status === 'complete' ? 'alert-success' : 'alert-warning'; const icon = status === 'error' ? 'bi-x-circle-fill' : status === 'complete' ? 'bi-check-circle-fill' : 'bi-exclamation-triangle-fill'; let html = `
    ${status === 'complete' ? 'Import successful' : status === 'error' ? 'Import failed' : 'Import completed with issues'}
    ${(result.billsImported != null || result.paymentsImported != null) ? `
    ${result.billsImported ?? 0} Bills Imported
    ${result.paymentsImported ?? 0} Payments Applied
    ` : `
    ${result.totalRecords ?? 0} Total
    ${result.importedCount ?? 0} Imported
    ${result.updatedCount ?? 0} Updated
    `} ${(result.skippedCount ?? 0) > 0 ? `
    ${result.skippedCount} Skipped
    ` : ''} ${(result.alreadyRecordedCount ?? 0) > 0 ? `
    ${result.alreadyRecordedCount} Already Recorded
    ` : ''}
    ${result.errorCount ?? 0} Errors
    `; if (result.errors && result.errors.length > 0) { html += `
    ${result.errors.length} error message(s) — click to expand
    `; } if (result.warnings && result.warnings.length > 0) { html += `
    ${result.warnings.length} warning(s) — click to expand
    `; } html += '
    '; return html; } function renderFooterButtons() { const btnBack = document.getElementById('qbwBtnBack'); const btnSkip = document.getElementById('qbwBtnSkip'); const btnNext = document.getElementById('qbwBtnNext'); const btnFinish = document.getElementById('qbwBtnFinish'); if (!btnBack) return; const isLast = currentStepIdx === activeSteps.length - 1; const st = stepState[activeSteps[currentStepIdx].key]; btnBack.classList.toggle('d-none', currentStepIdx === 0); btnSkip.classList.toggle('d-none', isLast || st.status === 'complete'); btnNext.classList.toggle('d-none', isLast); btnFinish.classList.toggle('d-none', !isLast); // Disable Next if current step is still pending (not complete/skipped) const canAdvance = st.status === 'complete' || st.status === 'skipped' || st.status === 'blocked'; btnNext.disabled = !canAdvance; if (isLast) { btnFinish.disabled = false; } } // ── Navigation ──────────────────────────────────────────────────────────── window.qbwGoTo = function (idx) { if (idx < 0 || idx >= activeSteps.length) return; currentStepIdx = idx; renderWizard(); }; window.qbwBack = function () { if (currentStepIdx > 0) { currentStepIdx--; renderWizard(); } }; window.qbwNext = function () { if (currentStepIdx < activeSteps.length - 1) { currentStepIdx++; renderWizard(); } }; window.qbwSkip = function () { const step = activeSteps[currentStepIdx]; stepState[step.key].status = 'skipped'; refreshBlocked(); saveState(); window.qbwNext(); }; window.qbwFinish = function () { saveState(); // Show completion message on Step 2 page if present const completionMsg = document.getElementById('qbWizardCompletionAlert'); if (completionMsg) completionMsg.classList.remove('d-none'); }; // ── Import ──────────────────────────────────────────────────────────────── window.qbwRunImport = async function (stepIdx) { const step = activeSteps[stepIdx]; const fileInput = document.getElementById(`qbwFile-${step.id}`); const importBtn = document.getElementById(`qbwImportBtn-${step.id}`); const spinner = document.getElementById(`qbwImportSpinner-${step.id}`); const resultContainer = document.getElementById(`qbwResultContainer-${step.id}`); if (!fileInput || !fileInput.files.length) { showToast('Please select a file to import.', 'warning'); return; } importBtn.disabled = true; spinner?.classList.remove('d-none'); resultContainer.innerHTML = ''; try { const formData = new FormData(); formData.append('file', fileInput.files[0]); // Include anti-forgery token if present on page const aft = document.querySelector('input[name="__RequestVerificationToken"]'); if (aft) formData.append('__RequestVerificationToken', aft.value); const resp = await fetch(step.endpoint, { method: 'POST', body: formData }); if (!resp.ok) { throw new Error(`Server returned ${resp.status}`); } const data = await resp.json(); const result = data.result || data; // Errors array contains ImportErrorDto objects — split into hard errors vs warnings by severity const allErrors = result.errors ?? result.Errors ?? []; const toStr = e => (typeof e === 'string') ? e : (e.displayMessage ?? e.errorMessage ?? JSON.stringify(e)); const hardErrors = allErrors.filter(e => !e.severity || e.severity === 'Error'); const warnings = allErrors.filter(e => e.severity === 'Warning' || e.severity === 'Skipped'); const normalised = { totalRecords: result.totalRecords ?? result.TotalRecords ?? 0, importedCount: result.importedCount ?? result.ImportedCount ?? 0, updatedCount: result.updatedCount ?? result.UpdatedCount ?? 0, skippedCount: result.skippedCount ?? result.SkippedCount ?? 0, alreadyRecordedCount: result.alreadyRecordedCount ?? result.AlreadyRecordedCount ?? 0, billsImported: result.billsImported ?? result.BillsImported ?? null, paymentsImported: result.paymentsImported ?? result.PaymentsImported ?? null, errorCount: hardErrors.length, errors: hardErrors.map(toStr), warnings: warnings.map(toStr) }; // Hard failure = server returned success:false AND nothing was imported at all const hasHardError = result.success === false && normalised.importedCount === 0 && normalised.updatedCount === 0; const tooManyErrors = false; // covered by hasHardError logic above if (hasHardError || tooManyErrors) { stepState[step.key] = { status: 'error', result: normalised }; // Prompt: continue anyway? const proceed = confirm( `Import completed with ${normalised.errorCount} error(s) out of ${normalised.totalRecords} records.\n\n` + 'Do you want to mark this step as done and continue anyway?\n' + 'Click OK to continue, or Cancel to stay on this step and review the errors.' ); if (proceed) { stepState[step.key].status = 'complete'; } } else if (normalised.errorCount > 0) { // Partial errors — still counts as complete but show warning stepState[step.key] = { status: 'complete', result: normalised }; showToast(`${step.title} imported with ${normalised.errorCount} errors. Review the details below.`, 'warning'); } else { stepState[step.key] = { status: 'complete', result: normalised }; showToast(`${step.title} imported successfully!`, 'success'); } refreshBlocked(); saveState(); renderWizard(); } catch (err) { const errResult = { totalRecords: 0, importedCount: 0, updatedCount: 0, skippedCount: 0, errorCount: 1, errors: [err.message], warnings: [] }; stepState[step.key] = { status: 'error', result: errResult }; refreshBlocked(); saveState(); renderWizard(); showToast('Import failed: ' + err.message, 'danger'); } finally { // Re-enable button (in case re-render didn't) const btn2 = document.getElementById(`qbwImportBtn-${step.id}`); if (btn2) btn2.disabled = false; const sp2 = document.getElementById(`qbwImportSpinner-${step.id}`); if (sp2) sp2.classList.add('d-none'); } }; // ── Persistence ─────────────────────────────────────────────────────────── async function saveState() { try { const payload = JSON.stringify({ source: activeSource, steps: stepState }); await fetch('/SetupWizard/SaveQbMigrationState', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: payload }) }); } catch (e) { // Non-fatal } } // ── Toast Helper ────────────────────────────────────────────────────────── function showToast(message, type) { // Use existing site toast infrastructure if available if (typeof window.showNotification === 'function') { window.showNotification(message, type); return; } // Fallback: simple Bootstrap toast const container = document.getElementById('toastContainer') || createToastContainer(); const id = 'qbwToast-' + Date.now(); const bgClass = type === 'success' ? 'bg-success' : type === 'warning' ? 'bg-warning' : type === 'danger' ? 'bg-danger' : 'bg-primary'; const textClass = type === 'warning' ? 'text-dark' : 'text-white'; const html = ``; container.insertAdjacentHTML('beforeend', html); const el = document.getElementById(id); if (el) new bootstrap.Toast(el, { delay: 5000 }).show(); } function createToastContainer() { const div = document.createElement('div'); div.id = 'toastContainer'; div.className = 'toast-container position-fixed bottom-0 end-0 p-3'; div.style.zIndex = '9999'; document.body.appendChild(div); return div; } function escHtml(str) { return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } })();