Initial commit
This commit is contained in:
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* 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, '"');
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user