644 lines
32 KiB
JavaScript
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 & 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 & 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 & 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…
|
|
</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
})();
|