Files
PowderCoatingLogix/src/PowderCoating.Web/wwwroot/js/qb-migration-wizard.js
T
2026-04-23 21:38:24 -04:00

644 lines
32 KiB
JavaScript

/**
* 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 <strong>Lists → Chart of Accounts</strong>.',
'Click the <strong>Account</strong> button at the bottom of the list.',
'Choose <strong>Import/Export → Export Chart of Accounts</strong> (or use <em>File → Utilities → Export → Lists to IIF Files → Chart of Accounts</em>).',
'Save the <code>.iif</code> 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 <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
'Check <strong>Customer List</strong> and click OK.',
'Save the <code>.iif</code> 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 <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
'Check <strong>Vendor List</strong> and click OK.',
'Save the <code>.iif</code> 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 <strong>File → Utilities → Export → Lists to IIF Files</strong>.',
'Check <strong>Item List</strong> and click OK.',
'Save the <code>.iif</code> 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 <strong>Reports → Inventory → Inventory Valuation Summary</strong>.',
'Click <strong>Customize Report</strong>, open the <strong>Display</strong> tab, and add the <strong>Preferred Vendor</strong> column.',
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> 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 <strong>Reports → Customers &amp; Receivables → Customer Balance Detail</strong>.',
'Set the date range to <strong>All</strong> to cover your full history.',
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> 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 <strong>Reports → Customers &amp; Receivables → Transaction List by Customer</strong>.',
'Set the date range to <strong>All</strong> to cover your full history.',
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> 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 <strong>Reports → Vendors &amp; Payables → Vendor Balance Detail</strong>.',
'Set the date range to <strong>All</strong> to cover your full history.',
'Click <strong>Excel → Create New Worksheet → Comma Separated Values (.csv)</strong> 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 <strong>Accounting → Chart of Accounts</strong>.',
'Click the <strong>Run Report</strong> button at the top right.',
'Click the <strong>Export</strong> icon (spreadsheet icon) and choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> 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 <strong>Reports</strong> and search for <strong>Customer Contact List</strong>.',
'Click <strong>Customize</strong> if you want to include additional fields (address, terms, balance).',
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> 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 <strong>Reports</strong> and search for <strong>Vendor Contact List</strong>.',
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> 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 <strong>Sales → Products and Services</strong>.',
'Click the <strong>More</strong> (⋮) button at the top right.',
'Choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> 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 <strong>Reports</strong> and search for <strong>Invoice List</strong>.',
'Set the date range to cover all of your history.',
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> 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 <strong>Reports</strong> and search for <strong>Transaction List by Date</strong>.',
'Set the date range to cover all of your history.',
'Click the <strong>Export</strong> icon and choose <strong>Export to Excel</strong>.',
'Save the <code>.xlsx</code> 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 = '<i class="bi bi-check-lg"></i>';
else if (st === 'skipped') icon = '<i class="bi bi-skip-forward-fill"></i>';
else if (st === 'error') icon = '<i class="bi bi-exclamation-triangle-fill"></i>';
else if (st === 'blocked') icon = '<i class="bi bi-lock-fill"></i>';
else icon = s.id;
return `<div class="qbw-dot ${statusClass}" onclick="qbwGoTo(${idx})" title="${s.title}">
<div class="qbw-dot-circle">${icon}</div>
<div class="qbw-dot-label">${s.title}</div>
</div>`;
}).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 => `<li class="mb-1">${i}</li>`).join('');
return `
<div class="row g-4">
<div class="col-12">
<div class="d-flex align-items-start gap-3 mb-3">
<div class="rounded-3 p-3 d-flex align-items-center justify-content-center flex-shrink-0"
style="background:var(--bs-primary-bg-subtle); color:var(--bs-primary); width:60px; height:60px;">
<i class="bi ${step.icon} fs-3"></i>
</div>
<div>
<h5 class="mb-1">${step.title}</h5>
<p class="text-secondary mb-0">${step.intro}</p>
</div>
</div>
${isBlocked ? `
<div class="alert alert-warning d-flex gap-2 mb-3">
<i class="bi bi-lock-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Step locked.</strong>
This step requires the following to be completed first:
<strong>${blockedNames}</strong>.
Go back and complete those steps, then return here.
</div>
</div>` : ''}
<div class="accordion mb-3" id="qbwInstructions-${step.id}">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button ${st.status === 'complete' ? 'collapsed' : ''}" type="button"
data-bs-toggle="collapse"
data-bs-target="#qbwInstructionsBody-${step.id}"
aria-expanded="${st.status === 'complete' ? 'false' : 'true'}">
<i class="bi bi-info-circle me-2"></i>How to export from QuickBooks Desktop
</button>
</h2>
<div id="qbwInstructionsBody-${step.id}" class="accordion-collapse collapse ${st.status === 'complete' ? '' : 'show'}">
<div class="accordion-body pb-2">
<ol class="mb-0">${instructionItems}</ol>
</div>
</div>
</div>
</div>
${!isBlocked ? `
<div class="qbw-upload-zone mb-3">
<label class="form-label fw-medium mb-2" for="qbwFile-${step.id}">
<i class="bi bi-cloud-upload me-1"></i>Select file to import
<span class="text-secondary fw-normal">(${step.fileAccept})</span>
</label>
<input type="file" class="form-control" id="qbwFile-${step.id}" accept="${step.fileAccept}">
</div>
<button class="btn btn-primary" id="qbwImportBtn-${step.id}"
onclick="qbwRunImport(${currentStepIdx})"
${isBlocked ? 'disabled' : ''}>
<i class="bi bi-cloud-upload me-1"></i>Import Now
</button>
<div id="qbwImportSpinner-${step.id}" class="d-none d-inline-flex align-items-center gap-2 ms-3 text-secondary">
<span class="spinner-border spinner-border-sm"></span> Importing&hellip;
</div>` : ''}
${resultHtml ? `<div class="mt-3 qbw-result-box">${resultHtml}</div>` : ''}
<div id="qbwResultContainer-${step.id}" class="mt-3 qbw-result-box"></div>
</div>
</div>`;
}
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 = `<div class="alert ${alertClass} mb-0">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi ${icon}"></i>
<strong>${status === 'complete' ? 'Import successful' : status === 'error' ? 'Import failed' : 'Import completed with issues'}</strong>
</div>
<div class="row g-2 text-center mb-2">
${(result.billsImported != null || result.paymentsImported != null) ? `
<div class="col-auto"><span class="badge bg-success"><i class="bi bi-file-earmark-text me-1"></i>${result.billsImported ?? 0} Bills Imported</span></div>
<div class="col-auto"><span class="badge bg-info"><i class="bi bi-credit-card me-1"></i>${result.paymentsImported ?? 0} Payments Applied</span></div>
` : `
<div class="col-auto"><span class="badge bg-secondary">${result.totalRecords ?? 0} Total</span></div>
<div class="col-auto"><span class="badge bg-success">${result.importedCount ?? 0} Imported</span></div>
<div class="col-auto"><span class="badge bg-info">${result.updatedCount ?? 0} Updated</span></div>
`}
${(result.skippedCount ?? 0) > 0 ? `<div class="col-auto"><span class="badge bg-secondary">${result.skippedCount} Skipped</span></div>` : ''}
${(result.alreadyRecordedCount ?? 0) > 0 ? `<div class="col-auto"><span class="badge bg-secondary">${result.alreadyRecordedCount} Already Recorded</span></div>` : ''}
<div class="col-auto"><span class="badge bg-danger">${result.errorCount ?? 0} Errors</span></div>
</div>`;
if (result.errors && result.errors.length > 0) {
html += `<details><summary class="small fw-medium">${result.errors.length} error message(s) — click to expand</summary>
<ul class="small mt-1 mb-0">
${result.errors.slice(0, 20).map(e => `<li>${escHtml(e)}</li>`).join('')}
${result.errors.length > 20 ? `<li>… and ${result.errors.length - 20} more</li>` : ''}
</ul></details>`;
}
if (result.warnings && result.warnings.length > 0) {
html += `<details><summary class="small fw-medium">${result.warnings.length} warning(s) — click to expand</summary>
<ul class="small mt-1 mb-0">
${result.warnings.slice(0, 10).map(w => `<li>${escHtml(w)}</li>`).join('')}
</ul></details>`;
}
html += '</div>';
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 = `<div id="${id}" class="toast align-items-center ${bgClass} ${textClass} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${escHtml(message)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
})();