/** * accounting-view.js * * Phase 1: Accounts Overview, Register, Reports * Phase 2 Lieferung 2: Expenses-Section + Auto-Sync-on-First-Open-of-Day */ import '../utils/api.js'; import { formatDate } from '../utils/helpers.js'; import { openExpenseModal } from '../modals/expense-modal.js'; import { openRefundModal } from '../modals/refund-modal.js'; import { fmtMoney, escapeHtml, showError, showLoading, todayISO, firstOfMonthISO, lastOfMonthISO, prevMonthISO, firstOfYearISO } from '../utils/report-helpers.js'; // ──────────────────────────────────────────────────────────────────── // State (modul-lokal) // ──────────────────────────────────────────────────────────────────── let allAccounts = []; let registerAccountId = null; let registerStartDate = null; let registerEndDate = null; let registerLoadSeq = 0; let expStartDate = null; let expEndDate = null; let expOnlyMine = false; // Auto-Sync nur einmal pro View-Mount let autoSyncDoneThisOpen = false; function makeCollapsible(headerText, contentId, startCollapsed = false) { return `

${escapeHtml(headerText)}

`; } export function toggleSection(contentId, headerEl) { const content = document.getElementById(contentId); if (!content) return; const isHidden = content.classList.toggle('hidden'); const arrow = headerEl.querySelector('svg'); if (arrow) arrow.classList.toggle('rotate-90', !isHidden); } // ──────────────────────────────────────────────────────────────────── // Toolbar // ──────────────────────────────────────────────────────────────────── export function injectToolbar() { const c = document.getElementById('accounting-toolbar'); if (!c) return; c.innerHTML = `

Accounting

read-only registers · expense entry
`; } // ──────────────────────────────────────────────────────────────────── // Auto-Sync beim ersten Öffnen des Tages // ──────────────────────────────────────────────────────────────────── async function maybeAutoSyncCaches() { if (autoSyncDoneThisOpen) return; autoSyncDoneThisOpen = true; try { const status = await window.API.accounting.getSyncStatus(); const accStale = status.accounts && status.accounts.staleToday; const venStale = status.vendors && status.vendors.staleToday; if (!accStale && !venStale) { updateSyncStatusBadge(status); return; } const syncBadge = document.getElementById('accounting-sync-status'); if (syncBadge) syncBadge.textContent = '🔄 Syncing caches…'; const tasks = []; if (accStale) tasks.push(window.API.accounting.syncAccounts()); if (venStale) tasks.push(window.API.accounting.syncVendors()); await Promise.all(tasks); const newStatus = await window.API.accounting.getSyncStatus(); updateSyncStatusBadge(newStatus); console.log('✅ Auto-synced QBO caches (first open of day)'); } catch (err) { console.warn('Auto-sync failed:', err.message); const syncBadge = document.getElementById('accounting-sync-status'); if (syncBadge) syncBadge.textContent = '⚠️ Sync failed'; } } export async function manualSync() { const syncBadge = document.getElementById('accounting-sync-status'); if (syncBadge) syncBadge.textContent = '🔄 Syncing…'; try { await Promise.all([ window.API.accounting.syncAccounts(), window.API.accounting.syncVendors() ]); const status = await window.API.accounting.getSyncStatus(); updateSyncStatusBadge(status); } catch (err) { if (syncBadge) syncBadge.textContent = '⚠️ Sync failed'; alert('Sync failed: ' + err.message); } } function updateSyncStatusBadge(status) { const el = document.getElementById('accounting-sync-status'); if (!el) return; const a = status.accounts; const v = status.vendors; if (a?.last_synced_at && v?.last_synced_at) { const aTime = new Date(a.last_synced_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); el.innerHTML = `Synced @ ${aTime} · ${a.last_sync_count} accts · ${v.last_sync_count} vendors`; } else { el.textContent = 'Not yet synced'; } } // ──────────────────────────────────────────────────────────────────── // Accounts Overview // ──────────────────────────────────────────────────────────────────── export async function loadAccountsOverview() { const slot = 'accounting-accounts'; showLoading(slot, 'Loading accounts from QBO…'); try { const accounts = await window.API.accounting.getAccounts(null, true); if (accounts.error) return showError(slot, accounts.error); allAccounts = accounts; const cards = accounts.filter(a => a.accountType === 'Bank' || a.accountType === 'Credit Card'); const el = document.getElementById(slot); if (!cards.length) { el.innerHTML = `
No bank or credit card accounts found.
`; } else { el.innerHTML = `
${cards.map(renderAccountCard).join('')}
`; } populateRegisterAccountDropdown(cards); } catch (err) { console.error('Accounts load failed:', err); showError(slot, err.message || 'Failed to load accounts'); } } function renderAccountCard(a) { const isBank = a.accountType === 'Bank'; const accent = isBank ? 'border-blue-200 bg-blue-50' : 'border-purple-200 bg-purple-50'; const label = isBank ? 'Bank' : 'Credit Card'; const labelColor = isBank ? 'text-blue-700' : 'text-purple-700'; const balText = a.currentBalance != null ? fmtMoney(a.currentBalance) : '—'; return `
${label} #${a.id}
${escapeHtml(a.name)}
${balText}
${a.accountSubType ? `
${escapeHtml(a.accountSubType)}
` : ''}
`; } // ──────────────────────────────────────────────────────────────────── // Customer Credits // // Kunden mit negativem A/R-Saldo in QBO — typischerweise Doppelzahlungen, die // im Banking-Screen als Einzahlung auf Accounts Receivable aufgelöst wurden. // Führend ist QBO; die App hält dazu keinen eigenen Bestand. // ──────────────────────────────────────────────────────────────────── export async function loadCustomerCredits() { const slot = 'accounting-credits'; showLoading(slot, 'Loading customer credits from QBO…'); try { const credits = await window.API.accounting.getCustomerCredits(); if (credits.error) return showError(slot, credits.error); const el = document.getElementById(slot); if (!credits.length) { el.innerHTML = `
No customer has a credit balance.
`; return; } const total = credits.reduce((s, c) => s + (Number(c.credit) || 0), 0); el.innerHTML = `
${credits.map(c => ` `).join('')}
Customer Credit
${escapeHtml(c.name)} ${fmtMoney(c.credit)}
TOTAL (${credits.length} customer${credits.length === 1 ? '' : 's'}) ${fmtMoney(total)}

Net A/R balance from QBO. A customer with a credit and open invoices at the same time nets out and is not listed here. Apply a credit in QBO when receiving payment, then run Sync payments.

`; } catch (err) { console.error('Customer credits load failed:', err); showError(slot, err.message || 'Failed to load customer credits'); } } // ──────────────────────────────────────────────────────────────────── // Register // ──────────────────────────────────────────────────────────────────── function populateRegisterAccountDropdown(bankCardAccounts) { const sel = document.getElementById('reg-account'); if (!sel) return; const current = registerAccountId || sel.value; sel.innerHTML = `` + bankCardAccounts.map(a => ``).join(''); if (current && bankCardAccounts.find(a => a.id === current)) sel.value = current; } export function injectRegisterControls() { const c = document.getElementById('accounting-register-controls'); if (!c) return; if (!registerStartDate) registerStartDate = firstOfMonthISO(); if (!registerEndDate) registerEndDate = todayISO(); c.innerHTML = ` ${makeCollapsible('Register', 'register-section-body')}
`; } export function selectRegisterAccount(accountId) { registerAccountId = accountId; const sel = document.getElementById('reg-account'); if (sel) sel.value = accountId; loadRegister(); } export async function loadRegister() { const sel = document.getElementById('reg-account'); const start = document.getElementById('reg-start'); const end = document.getElementById('reg-end'); if (!sel || !sel.value) { const slot = document.getElementById('accounting-register-table'); if (slot) slot.innerHTML = `

Select an account to view the register.

`; return; } registerAccountId = sel.value; registerStartDate = start.value; registerEndDate = end.value; const slot = 'accounting-register-table'; showLoading(slot, 'Loading register from QBO…'); const mySeq = ++registerLoadSeq; try { const result = await window.API.accounting.getRegister(registerAccountId, registerStartDate, registerEndDate); if (mySeq !== registerLoadSeq) return; if (result.error) return showError(slot, result.error); renderRegisterTable(result); } catch (err) { if (mySeq !== registerLoadSeq) return; console.error('Register load failed:', err); showError(slot, err.message || 'Failed to load register'); } } function renderRegisterTable(result) { const el = document.getElementById('accounting-register-table'); if (!el) return; let rows = (result.rows || []).slice().sort((a, b) => (b.date || '').localeCompare(a.date || '')); const meta = result.meta || {}; if (!rows.length) { el.innerHTML = `
No transactions in selected range.
`; return; } const tbody = rows.map(renderRegisterRow).join(''); el.innerHTML = `
${escapeHtml(meta.reportName || 'Transaction List')} ${meta.startPeriod ? '— ' + escapeHtml(meta.startPeriod) : ''} ${meta.endPeriod ? ' to ' + escapeHtml(meta.endPeriod) : ''}
${rows.length} ${rows.length === 1 ? 'transaction' : 'transactions'}
${tbody}
Date Type No. Payee Split / Category Memo Amount
`; } function renderRegisterRow(r) { const isSplit = r.splitAccount === '-Split-'; const splitContent = isSplit ? renderSplitCell(r) : escapeHtml(r.splitAccount || ''); return ` ${escapeHtml(r.date || '')} ${escapeHtml(r.type || '')} ${escapeHtml(r.docNum || '')} ${escapeHtml(r.payee || '')} ${splitContent} ${escapeHtml(r.memo || '')} ${r.amount != null ? fmtMoney(r.amount) : ''} `; } function renderSplitCell(r) { if (!r.splits || !r.splits.length) { const type = (r.type || '').toLowerCase(); if (type.includes('tax payment')) { return `-Split- (Sales Tax)`; } if (type.includes('paycheck') || type.includes('payroll')) { return `-Split- (Payroll)`; } return `-Split-`; } const lines = r.splits.map(s => `
${escapeHtml(s.account || '?')} ${s.amount != null ? fmtMoney(s.amount) : ''}
`).join(''); return `
${lines}
`; } // ════════════════════════════════════════════════════════════════════ // Phase 2 Lieferung 2 — Expenses Section // ════════════════════════════════════════════════════════════════════ export function injectExpensesSection() { const c = document.getElementById('accounting-expenses'); if (!c) return; if (!expStartDate) expStartDate = firstOfMonthISO(); if (!expEndDate) expEndDate = todayISO(); c.innerHTML = ` ${makeCollapsible('Expenses', 'expenses-section-body')}
`; } export async function loadExpenses() { const startEl = document.getElementById('exp-start'); const endEl = document.getElementById('exp-end'); const onlyEl = document.getElementById('exp-only-mine'); expStartDate = startEl.value; expEndDate = endEl.value; expOnlyMine = onlyEl.checked; const slot = 'accounting-expenses-table'; showLoading(slot, 'Loading expenses from QBO…'); try { const list = await window.API.accounting.listExpenses(expStartDate, expEndDate, expOnlyMine); if (list.error) return showError(slot, list.error); renderExpensesTable(list); } catch (err) { showError(slot, err.message || 'Failed to load expenses'); } } function renderExpensesTable(expenses) { const el = document.getElementById('accounting-expenses-table'); if (!el) return; if (!expenses.length) { el.innerHTML = `
No expenses in selected range${expOnlyMine ? ' (created from this app)' : ''}.
`; return; } const sorted = expenses.slice().sort((a, b) => (b.txnDate || '').localeCompare(a.txnDate || '')); const tbody = sorted.map(e => { const splitsHtml = e.lines && e.lines.length > 1 ? `
${e.lines.length} lines
${e.lines.map(l => `
${escapeHtml(l.accountName || '?')} ${l.amount != null ? fmtMoney(l.amount) : ''}
`).join('')}
` : escapeHtml(e.lines[0]?.accountName || ''); const editBtn = expOnlyMine ? `` : ``; return ` ${escapeHtml(e.txnDate || '')} ${escapeHtml(e.vendorName || '')} ${escapeHtml(e.accountName || '')} ${splitsHtml} ${escapeHtml(e.refNo || '')} ${escapeHtml(e.memo || '')} ${fmtMoney(e.totalAmt)} ${editBtn} `; }).join(''); el.innerHTML = `
${tbody}
Date Vendor Payment Account Category Ref Memo Amount Action
${sorted.length} expense${sorted.length === 1 ? '' : 's'}
`; } export async function openNewExpense() { await openExpenseModal({ onSaved: () => loadExpenses() }); } // ──────────────────────────────────────────────────────────────────── // Init / Public Entry Points // ──────────────────────────────────────────────────────────────────── export function renderAccountingView() { autoSyncDoneThisOpen = false; injectToolbar(); injectRegisterControls(); injectExpensesSection(); maybeAutoSyncCaches().then(() => { loadAccountsOverview(); loadCustomerCredits(); }); } export function refreshAll() { loadAccountsOverview(); loadCustomerCredits(); if (registerAccountId) loadRegister(); } export async function editExpense(expenseJson) { let expense; try { expense = typeof expenseJson === 'string' ? JSON.parse(expenseJson) : expenseJson; } catch (e) { alert('Could not open expense for editing.'); return; } await openExpenseModal({ expense, onSaved: () => loadExpenses() }); } export async function openNewRefund() { await openRefundModal({ onSaved: (result) => { alert(`✅ Refund recorded: ${fmtMoney(result.totalAmt)} from ${result.vendorName}\nDeposit #${result.id} — booked to ${result.categoryName}`); loadExpenses(); } }); } window.accountingView = { renderAccountingView, refreshAll, manualSync, loadAccountsOverview, loadCustomerCredits, loadRegister, loadExpenses, openNewExpense, openNewRefund, editExpense, selectRegisterAccount, toggleSection };