Add QuickBooks Online CSV importer for checks and deposits

Two-tab modal: "Checks to Print" parses QBO Transaction List / Check
Detail CSV exports; "Deposits" parses QBO Deposit Detail CSV exports
and groups by date. Both tabs show a preview before confirming import.
This commit is contained in:
2026-03-18 14:55:21 -06:00
parent c944c84939
commit 35a5d576ea
5 changed files with 598 additions and 0 deletions
+32
View File
@@ -85,6 +85,38 @@ header {
line-height: 1.5;
}
.modal-wide { width: min(720px, 96vw); }
.qbo-tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: #f9f9f9;
padding: 0 16px;
}
.qbo-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
color: var(--text-muted);
margin-bottom: -1px;
}
.qbo-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
.qbo-tab:hover:not(.active) { color: var(--text); }
.qbo-preview-count { font-size: 12px; color: var(--text-muted); margin-bottom: 6px; }
.qbo-warnings { font-size: 11px; color: #b45309; background: #fef9c3; border: 1px solid #ca8a04; border-radius: 3px; padding: 6px 8px; margin-bottom: 8px; }
.qbo-preview-scroll { max-height: 280px; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px; }
.qbo-preview-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.qbo-preview-table th { background: #f5f5f5; padding: 5px 8px; text-align: left; font-weight: 600; position: sticky; top: 0; border-bottom: 1px solid var(--border); }
.qbo-preview-table td { padding: 4px 8px; border-bottom: 1px solid #f0f0f0; }
.qbo-preview-table tr:last-child td { border-bottom: none; }
.qbo-preview-table .text-muted { color: var(--text-muted); }
.import-result { font-size: 13px; color: #15803d; background: #f0fdf4; border: 1px solid #86efac; border-radius: 4px; padding: 8px 12px; margin-top: 8px; }
/* ── Toolbar ── */
.toolbar {
display: flex;
+51
View File
@@ -42,6 +42,7 @@
</button>
<button id="btn-new-check" class="btn-secondary">+ New Check</button>
<button id="btn-import" class="btn-secondary">Import .mdb</button>
<button class="btn-secondary" data-open-qbo="checks">Import QBO</button>
</div>
</div>
@@ -83,6 +84,7 @@
</div>
<div class="toolbar-right">
<button id="btn-new-deposit" class="btn-secondary">+ New Deposit</button>
<button class="btn-secondary" data-open-qbo="deposits">Import QBO</button>
</div>
</div>
<div class="table-wrap">
@@ -411,6 +413,55 @@
</div>
</div>
<!-- QBO Import modal -->
<div id="qbo-import-overlay" class="modal-overlay"></div>
<div id="qbo-import-modal" class="modal modal-wide" role="dialog" aria-labelledby="qbo-import-title">
<div class="modal-header">
<h2 id="qbo-import-title">Import from QuickBooks Online</h2>
<button id="btn-close-qbo-import" class="btn-icon" title="Close">×</button>
</div>
<div class="qbo-tabs">
<button class="qbo-tab active" data-tab="checks">Checks to Print</button>
<button class="qbo-tab" data-tab="deposits">Deposits</button>
</div>
<!-- Checks pane -->
<div class="qbo-pane" id="qbo-pane-checks">
<div class="modal-body">
<p class="modal-desc">Export a <strong>Transaction List</strong> or <strong>Check Detail</strong> report from QBO (filtered to checks) as CSV, then upload it here. Checks will be imported as unprinted.</p>
<div class="form-group">
<label for="qbo-checks-file">QBO CSV export</label>
<input type="file" id="qbo-checks-file" accept=".csv,.txt">
</div>
<div id="qbo-checks-preview" hidden></div>
<div id="qbo-checks-result" class="import-result" hidden></div>
<div id="qbo-checks-error" class="wizard-error" hidden></div>
</div>
<div class="modal-footer">
<button id="btn-qbo-checks-parse" class="btn-secondary">Preview</button>
<button id="btn-qbo-checks-import" class="btn-primary" hidden disabled>Import</button>
<button id="btn-qbo-checks-cancel" class="btn-ghost">Cancel</button>
</div>
</div>
<!-- Deposits pane -->
<div class="qbo-pane" id="qbo-pane-deposits" hidden>
<div class="modal-body">
<p class="modal-desc">Export a <strong>Deposit Detail</strong> report from QBO as CSV. Deposits are grouped by date — one deposit record per day.</p>
<div class="form-group">
<label for="qbo-deposits-file">QBO CSV export</label>
<input type="file" id="qbo-deposits-file" accept=".csv,.txt">
</div>
<div id="qbo-deposits-preview" hidden></div>
<div id="qbo-deposits-result" class="import-result" hidden></div>
<div id="qbo-deposits-error" class="wizard-error" hidden></div>
</div>
<div class="modal-footer">
<button id="btn-qbo-deposits-parse" class="btn-secondary">Preview</button>
<button id="btn-qbo-deposits-import" class="btn-primary" hidden disabled>Import</button>
<button id="btn-qbo-deposits-cancel" class="btn-ghost">Cancel</button>
</div>
</div>
</div>
<!-- Deposit slide-in panel -->
<div id="dep-panel-overlay"></div>
<aside id="deposit-panel">
+198
View File
@@ -644,6 +644,188 @@ async function saveAccountSettings() {
}
}
// ── QBO Import ────────────────────────────────────────────────────────────────
let qboChecksRecords = null;
let qboDepositsRecords = null;
function openQboImport(tab) {
switchQboTab(tab || 'checks');
resetQboPane('checks');
resetQboPane('deposits');
document.getElementById('qbo-import-overlay').classList.add('open');
document.getElementById('qbo-import-modal').classList.add('open');
}
function closeQboImport() {
document.getElementById('qbo-import-overlay').classList.remove('open');
document.getElementById('qbo-import-modal').classList.remove('open');
}
function resetQboPane(type) {
document.getElementById(`qbo-${type}-file`).value = '';
document.getElementById(`qbo-${type}-preview`).hidden = true;
document.getElementById(`qbo-${type}-preview`).innerHTML = '';
document.getElementById(`qbo-${type}-result`).hidden = true;
document.getElementById(`qbo-${type}-result`).textContent = '';
document.getElementById(`qbo-${type}-error`).hidden = true;
document.getElementById(`qbo-${type}-error`).textContent = '';
document.getElementById(`btn-qbo-${type}-import`).hidden = true;
document.getElementById(`btn-qbo-${type}-import`).disabled = true;
if (type === 'checks') qboChecksRecords = null;
else qboDepositsRecords = null;
}
function switchQboTab(tab) {
document.querySelectorAll('.qbo-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.getElementById('qbo-pane-checks').hidden = tab !== 'checks';
document.getElementById('qbo-pane-deposits').hidden = tab !== 'deposits';
}
async function qboParseFile(type) {
const fileInput = document.getElementById(`qbo-${type}-file`);
const errEl = document.getElementById(`qbo-${type}-error`);
const previewEl = document.getElementById(`qbo-${type}-preview`);
const resultEl = document.getElementById(`qbo-${type}-result`);
const importBtn = document.getElementById(`btn-qbo-${type}-import`);
const parseBtn = document.getElementById(`btn-qbo-${type}-parse`);
errEl.hidden = true;
previewEl.hidden = true;
previewEl.innerHTML = '';
resultEl.hidden = true;
importBtn.hidden = true;
importBtn.disabled = true;
const file = fileInput.files[0];
if (!file) { errEl.textContent = 'Select a CSV file first.'; errEl.hidden = false; return; }
parseBtn.disabled = true;
parseBtn.textContent = 'Parsing\u2026';
try {
const fd = new FormData();
fd.append('file', file);
fd.append('type', type);
const resp = await fetch('/api/qbo-import/parse', { method: 'POST', body: fd });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Parse failed');
if (type === 'checks') {
qboChecksRecords = data.records;
previewEl.innerHTML = buildChecksPreviewHTML(data.records, data.warnings);
} else {
qboDepositsRecords = data.records;
previewEl.innerHTML = buildDepositsPreviewHTML(data.records, data.warnings);
}
previewEl.hidden = false;
const depCount = type === 'deposits' ? countDepositDates(data.records) : 0;
importBtn.textContent = type === 'checks'
? `Import ${data.records.length} Check${data.records.length !== 1 ? 's' : ''}`
: `Import ${depCount} Deposit${depCount !== 1 ? 's' : ''}`;
importBtn.hidden = false;
importBtn.disabled = false;
} catch (err) {
errEl.textContent = err.message;
errEl.hidden = false;
} finally {
parseBtn.disabled = false;
parseBtn.textContent = 'Preview';
}
}
function countDepositDates(records) {
return new Set(records.map(r => r.date)).size;
}
const fmtCurrency = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
const fmtDateDisp = iso => { const [y, m, d] = iso.split('-'); return `${m}/${d}/${y}`; };
function buildChecksPreviewHTML(records, warnings) {
let html = `<div class="qbo-preview-count">${records.length} check${records.length !== 1 ? 's' : ''} found</div>`;
if (warnings && warnings.length) {
html += `<div class="qbo-warnings">${warnings.map(w => escHtml(w)).join('<br>')}</div>`;
}
html += `<div class="qbo-preview-scroll"><table class="qbo-preview-table">
<thead><tr><th>Date</th><th>Payee</th><th style="text-align:right">Amount</th><th>Memo</th><th>Check #</th></tr></thead>
<tbody>`;
for (const r of records) {
html += `<tr>
<td>${escHtml(fmtDateDisp(r.date))}</td>
<td>${escHtml(r.payee || '')}</td>
<td style="text-align:right;font-family:monospace">${escHtml(fmtCurrency(r.amount))}</td>
<td class="text-muted">${escHtml(r.memo || '')}</td>
<td class="text-muted">${r.check_no ? escHtml(String(r.check_no)) : '<em>auto</em>'}</td>
</tr>`;
}
html += '</tbody></table></div>';
return html;
}
function buildDepositsPreviewHTML(records, warnings) {
const byDate = new Map();
for (const r of records) {
if (!byDate.has(r.date)) byDate.set(r.date, []);
byDate.get(r.date).push(r);
}
const dateCount = byDate.size;
let html = `<div class="qbo-preview-count">${records.length} item${records.length !== 1 ? 's' : ''} across ${dateCount} deposit${dateCount !== 1 ? 's' : ''}</div>`;
if (warnings && warnings.length) {
html += `<div class="qbo-warnings">${warnings.map(w => escHtml(w)).join('<br>')}</div>`;
}
html += `<div class="qbo-preview-scroll"><table class="qbo-preview-table">
<thead><tr><th>Date</th><th>Items</th><th style="text-align:right">Total</th></tr></thead>
<tbody>`;
for (const [date, items] of byDate) {
const total = items.reduce((s, i) => s + i.amount, 0);
html += `<tr>
<td>${escHtml(fmtDateDisp(date))}</td>
<td class="text-muted">${items.length} item${items.length !== 1 ? 's' : ''}</td>
<td style="text-align:right;font-family:monospace">${escHtml(fmtCurrency(total))}</td>
</tr>`;
}
html += '</tbody></table></div>';
return html;
}
async function qboConfirmImport(type) {
const records = type === 'checks' ? qboChecksRecords : qboDepositsRecords;
const errEl = document.getElementById(`qbo-${type}-error`);
const resultEl = document.getElementById(`qbo-${type}-result`);
const importBtn = document.getElementById(`btn-qbo-${type}-import`);
errEl.hidden = true;
importBtn.disabled = true;
importBtn.textContent = 'Importing\u2026';
try {
const data = await apiFetch('POST', '/api/qbo-import/confirm', {
type, records, account_id: state.activeAccountId,
});
if (type === 'checks') {
resultEl.textContent = `Imported ${data.imported} check${data.imported !== 1 ? 's' : ''}${data.skipped ? `, skipped ${data.skipped} duplicate${data.skipped !== 1 ? 's' : ''}` : ''}.`;
await loadChecks();
await loadAccounts();
renderHeader();
} else {
resultEl.textContent = `Imported ${data.imported} deposit${data.imported !== 1 ? 's' : ''} (${data.itemCount} items).`;
if (typeof loadDeposits === 'function') await loadDeposits();
}
resultEl.hidden = false;
importBtn.hidden = true;
document.getElementById(`qbo-${type}-file`).value = '';
if (type === 'checks') qboChecksRecords = null;
else qboDepositsRecords = null;
} catch (err) {
errEl.textContent = err.message;
errEl.hidden = false;
importBtn.disabled = false;
importBtn.textContent = type === 'checks' ? 'Import Checks' : 'Import Deposits';
}
}
// ── Set next check number ─────────────────────────────────────────────────────
function openSetCheckNo() {
@@ -1116,6 +1298,22 @@ function init() {
document.getElementById(id).addEventListener('input', recalcDepTotals);
});
// QBO Import
document.querySelectorAll('[data-open-qbo]').forEach(btn =>
btn.addEventListener('click', () => openQboImport(btn.dataset.openQbo))
);
document.getElementById('btn-close-qbo-import').addEventListener('click', closeQboImport);
document.getElementById('qbo-import-overlay').addEventListener('click', closeQboImport);
document.querySelectorAll('.qbo-tab').forEach(t =>
t.addEventListener('click', () => switchQboTab(t.dataset.tab))
);
document.getElementById('btn-qbo-checks-parse').addEventListener('click', () => qboParseFile('checks'));
document.getElementById('btn-qbo-deposits-parse').addEventListener('click', () => qboParseFile('deposits'));
document.getElementById('btn-qbo-checks-import').addEventListener('click', () => qboConfirmImport('checks'));
document.getElementById('btn-qbo-deposits-import').addEventListener('click', () => qboConfirmImport('deposits'));
document.getElementById('btn-qbo-checks-cancel').addEventListener('click', closeQboImport);
document.getElementById('btn-qbo-deposits-cancel').addEventListener('click', closeQboImport);
// Initial data load
loadAccounts();
}