Files
invoice-system/public/js/views/accounting-view.js
2026-05-07 10:06:14 -05:00

662 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
// ────────────────────────────────────────────────────────────────────
// State (modul-lokal)
// ────────────────────────────────────────────────────────────────────
let allAccounts = [];
let registerAccountId = null;
let registerStartDate = null;
let registerEndDate = null;
let registerLoadSeq = 0;
let plStartDate = null;
let plEndDate = null;
let plAccountingMethod = 'Accrual';
let bsAsOfDate = null;
let bsAccountingMethod = 'Accrual';
let expStartDate = null;
let expEndDate = null;
let expOnlyMine = false;
// Auto-Sync nur einmal pro View-Mount
let autoSyncDoneThisOpen = false;
// ────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────
function fmtMoney(n) {
if (n == null || isNaN(n)) return '';
return n.toLocaleString('en-US', {
style: 'currency', currency: 'USD',
minimumFractionDigits: 2, maximumFractionDigits: 2
});
}
function todayISO() { return new Date().toISOString().split('T')[0]; }
function firstOfMonthISO() {
const d = new Date();
return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split('T')[0];
}
function firstOfYearISO() {
const d = new Date();
return new Date(d.getFullYear(), 0, 1).toISOString().split('T')[0];
}
function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function showError(slotId, message) {
const el = document.getElementById(slotId);
if (!el) return;
el.innerHTML = `
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="font-semibold text-red-800">QBO Error</p>
<p class="text-sm text-red-600 mt-1">${escapeHtml(message)}</p>
</div>`;
}
function showLoading(slotId, message = 'Loading…') {
const el = document.getElementById(slotId);
if (!el) return;
el.innerHTML = `
<div class="flex items-center gap-3 p-4 text-gray-500">
<svg class="animate-spin h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>${escapeHtml(message)}</span>
</div>`;
}
// ────────────────────────────────────────────────────────────────────
// Toolbar
// ────────────────────────────────────────────────────────────────────
export function injectToolbar() {
const c = document.getElementById('accounting-toolbar');
if (!c) return;
c.innerHTML = `
<div class="flex items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
<h2 class="text-lg font-semibold text-gray-800">Accounting</h2>
<span class="text-sm text-gray-400">read-only registers · expense entry</span>
<div class="ml-auto flex items-center gap-2">
<span id="accounting-sync-status" class="text-xs text-gray-500"></span>
<button onclick="window.accountingView.manualSync()"
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">
🔄 Sync from QBO
</button>
<button onclick="window.accountingView.refreshAll()"
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">
↻ Refresh
</button>
</div>
</div>`;
}
// ────────────────────────────────────────────────────────────────────
// 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 = `<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg text-gray-600">No bank or credit card accounts found.</div>`;
} else {
el.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">${cards.map(renderAccountCard).join('')}</div>`;
}
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 `
<div class="rounded-lg border ${accent} p-4 cursor-pointer hover:shadow-md transition"
onclick="window.accountingView.selectRegisterAccount('${a.id}')">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold uppercase tracking-wide ${labelColor}">${label}</span>
<span class="text-xs text-gray-400">#${a.id}</span>
</div>
<div class="text-base font-semibold text-gray-900 mb-2">${escapeHtml(a.name)}</div>
<div class="text-2xl font-bold text-gray-900">${balText}</div>
${a.accountSubType ? `<div class="text-xs text-gray-500 mt-1">${escapeHtml(a.accountSubType)}</div>` : ''}
</div>`;
}
// ────────────────────────────────────────────────────────────────────
// Register
// ────────────────────────────────────────────────────────────────────
function populateRegisterAccountDropdown(bankCardAccounts) {
const sel = document.getElementById('reg-account');
if (!sel) return;
const current = registerAccountId || sel.value;
sel.innerHTML = `<option value="">— Select account —</option>` +
bankCardAccounts.map(a => `<option value="${a.id}">${escapeHtml(a.name)} (${a.accountType})</option>`).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 = `
<div class="flex flex-wrap items-end gap-3 mb-3 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Account</label>
<select id="reg-account" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-72">
<option value="">— Loading accounts —</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Start Date</label>
<input type="date" id="reg-start" value="${registerStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">End Date</label>
<input type="date" id="reg-end" value="${registerEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<button onclick="window.accountingView.loadRegister()"
class="px-4 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">
Load Register
</button>
</div>
<div id="accounting-register-table"></div>`;
}
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 = `<p class="text-sm text-gray-500 px-4 py-3">Select an account to view the register.</p>`;
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 = `<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg text-gray-600">No transactions in selected range.</div>`;
return;
}
const tbody = rows.map(renderRegisterRow).join('');
el.innerHTML = `
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b bg-gray-50 flex items-center justify-between">
<div class="text-xs text-gray-500">
${escapeHtml(meta.reportName || 'Transaction List')}
${meta.startPeriod ? '— ' + escapeHtml(meta.startPeriod) : ''}
${meta.endPeriod ? ' to ' + escapeHtml(meta.endPeriod) : ''}
</div>
<div class="text-sm font-semibold text-gray-700">
${rows.length} ${rows.length === 1 ? 'transaction' : 'transactions'}
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Date</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Type</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">No.</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Payee</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Split / Category</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Memo</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Amount</th>
</tr>
</thead>
<tbody>${tbody}</tbody>
</table>
</div>
</div>`;
}
function renderRegisterRow(r) {
const isSplit = r.splitAccount === '-Split-';
const splitContent = isSplit ? renderSplitCell(r) : escapeHtml(r.splitAccount || '');
return `
<tr class="border-t hover:bg-gray-50 align-top">
<td class="px-3 py-2 text-sm whitespace-nowrap">${escapeHtml(r.date || '')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(r.type || '')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(r.docNum || '')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(r.payee || '')}</td>
<td class="px-3 py-2 text-sm text-gray-600">${splitContent}</td>
<td class="px-3 py-2 text-sm text-gray-500">${escapeHtml(r.memo || '')}</td>
<td class="px-3 py-2 text-sm text-right whitespace-nowrap ${r.amount < 0 ? 'text-red-600' : 'text-gray-900'}">
${r.amount != null ? fmtMoney(r.amount) : ''}
</td>
</tr>`;
}
function renderSplitCell(r) {
if (!r.splits || !r.splits.length) return `<span class="text-gray-500 italic">-Split-</span>`;
const lines = r.splits.map(s => `
<div class="flex justify-between gap-3 text-xs">
<span class="text-gray-700">${escapeHtml(s.account || '?')}</span>
<span class="text-gray-600 whitespace-nowrap">${s.amount != null ? fmtMoney(s.amount) : ''}</span>
</div>`).join('');
return `<div class="space-y-0.5">${lines}</div>`;
}
// ────────────────────────────────────────────────────────────────────
// Reports
// ────────────────────────────────────────────────────────────────────
export function injectReportsControls() {
const c = document.getElementById('accounting-reports');
if (!c) return;
if (!plStartDate) plStartDate = firstOfYearISO();
if (!plEndDate) plEndDate = todayISO();
if (!bsAsOfDate) bsAsOfDate = todayISO();
c.innerHTML = `
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b bg-gray-50"><h3 class="font-semibold text-gray-800">Profit &amp; Loss</h3></div>
<div class="p-4">
<div class="flex flex-wrap items-end gap-3 mb-3">
<div><label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
<input type="date" id="pl-start" value="${plStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
<div><label class="block text-xs font-medium text-gray-700 mb-1">End</label>
<input type="date" id="pl-end" value="${plEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
<select id="pl-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="Accrual" ${plAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
<option value="Cash" ${plAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.accountingView.loadProfitLoss()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
</div>
<div id="pl-result"></div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b bg-gray-50"><h3 class="font-semibold text-gray-800">Balance Sheet</h3></div>
<div class="p-4">
<div class="flex flex-wrap items-end gap-3 mb-3">
<div><label class="block text-xs font-medium text-gray-700 mb-1">As of</label>
<input type="date" id="bs-asof" value="${bsAsOfDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
<select id="bs-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="Accrual" ${bsAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
<option value="Cash" ${bsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.accountingView.loadBalanceSheet()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
</div>
<div id="bs-result"></div>
</div>
</div>
</div>`;
}
export async function loadProfitLoss() {
plStartDate = document.getElementById('pl-start').value;
plEndDate = document.getElementById('pl-end').value;
plAccountingMethod = document.getElementById('pl-method').value;
showLoading('pl-result', 'Loading P&L from QBO…');
try {
const data = await window.API.accounting.getProfitAndLoss(plStartDate, plEndDate, plAccountingMethod);
if (data.error) return showError('pl-result', data.error);
document.getElementById('pl-result').innerHTML = renderQboReport(data);
} catch (err) { showError('pl-result', err.message || 'Failed to load P&L'); }
}
export async function loadBalanceSheet() {
bsAsOfDate = document.getElementById('bs-asof').value;
bsAccountingMethod = document.getElementById('bs-method').value;
showLoading('bs-result', 'Loading Balance Sheet from QBO…');
try {
const data = await window.API.accounting.getBalanceSheet(bsAsOfDate, bsAccountingMethod);
if (data.error) return showError('bs-result', data.error);
document.getElementById('bs-result').innerHTML = renderQboReport(data);
} catch (err) { showError('bs-result', err.message || 'Failed to load Balance Sheet'); }
}
function renderQboReport(report) {
if (!report || !report.Header) return `<p class="text-sm text-gray-500">No report data.</p>`;
const cols = (report.Columns && report.Columns.Column) || [];
const headerRow = cols.map(c => `<th class="px-3 py-2 text-right font-medium text-gray-700 first:text-left">${escapeHtml(c.ColTitle || '')}</th>`).join('');
const body = report.Rows && report.Rows.Row ? renderReportRows(report.Rows.Row, 0) : '';
return `
<div class="text-xs text-gray-500 mb-2">
${escapeHtml(report.Header.ReportName || '')}
${report.Header.StartPeriod ? '· ' + escapeHtml(report.Header.StartPeriod) + ' ' + escapeHtml(report.Header.EndPeriod) : ''}
${report.Header.ReportBasis ? '· ' + escapeHtml(report.Header.ReportBasis) : ''}
</div>
<div class="overflow-x-auto border border-gray-200 rounded">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 border-b"><tr>${headerRow}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
function renderReportRows(rows, depth) {
if (!rows) return '';
const arr = Array.isArray(rows) ? rows : [rows];
let html = '';
for (const row of arr) {
const isSection = row.type === 'Section' || row.Rows || row.Summary;
const indent = depth * 16;
if (row.Header && row.Header.ColData) {
const cells = row.Header.ColData.map((c, i) =>
i === 0
? `<td class="px-3 py-1.5 font-semibold text-gray-800" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
: `<td class="px-3 py-1.5 text-right text-gray-500"></td>`
).join('');
html += `<tr class="bg-gray-50">${cells}</tr>`;
}
if (isSection && row.Rows && row.Rows.Row) html += renderReportRows(row.Rows.Row, depth + 1);
if (row.Summary && row.Summary.ColData) {
const cells = row.Summary.ColData.map((c, i) =>
i === 0
? `<td class="px-3 py-1.5 font-semibold text-gray-700 border-t" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
: `<td class="px-3 py-1.5 text-right font-semibold text-gray-900 border-t">${escapeHtml(c.value || '')}</td>`
).join('');
html += `<tr>${cells}</tr>`;
}
if (!isSection && row.ColData) {
const cells = row.ColData.map((c, i) =>
i === 0
? `<td class="px-3 py-1.5" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
: `<td class="px-3 py-1.5 text-right">${escapeHtml(c.value || '')}</td>`
).join('');
html += `<tr>${cells}</tr>`;
}
}
return html;
}
// ════════════════════════════════════════════════════════════════════
// 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 = `
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 border-b bg-gray-50 flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
<input type="date" id="exp-start" value="${expStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">End</label>
<input type="date" id="exp-end" value="${expEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div class="flex items-center gap-2 pt-5">
<input type="checkbox" id="exp-only-mine" ${expOnlyMine ? 'checked' : ''}
class="h-4 w-4 text-blue-600 border-gray-300 rounded">
<label for="exp-only-mine" class="text-sm text-gray-700" title="Only show expenses created from this app">Only mine</label>
</div>
<button onclick="window.accountingView.loadExpenses()"
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">
Load
</button>
<div class="ml-auto">
<button onclick="window.accountingView.openNewExpense()"
class="px-4 py-1.5 bg-green-600 text-white rounded-md text-sm font-semibold hover:bg-green-700">
+ New Expense
</button>
</div>
</div>
<div id="accounting-expenses-table"></div>
</div>`;
}
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 = `<div class="p-4 text-gray-500 text-sm">No expenses in selected range${expOnlyMine ? ' (created from this app)' : ''}.</div>`;
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
? `<details><summary class="cursor-pointer text-blue-600 text-xs">${e.lines.length} lines</summary>
<div class="mt-1 space-y-0.5">
${e.lines.map(l => `
<div class="flex justify-between gap-3 text-xs">
<span class="text-gray-700">${escapeHtml(l.accountName || '?')}</span>
<span class="text-gray-600 whitespace-nowrap">${l.amount != null ? fmtMoney(l.amount) : ''}</span>
</div>`).join('')}
</div>
</details>`
: escapeHtml(e.lines[0]?.accountName || '');
return `
<tr class="border-t hover:bg-gray-50 align-top">
<td class="px-3 py-2 text-sm whitespace-nowrap">${escapeHtml(e.txnDate || '')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(e.vendorName || '')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(e.accountName || '')}</td>
<td class="px-3 py-2 text-sm text-gray-600">${splitsHtml}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(e.refNo || '')}</td>
<td class="px-3 py-2 text-sm text-gray-500">${escapeHtml(e.memo || '')}</td>
<td class="px-3 py-2 text-sm text-right whitespace-nowrap text-red-600">${fmtMoney(e.totalAmt)}</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Date</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Vendor</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Payment Account</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Category</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Ref</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Memo</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Amount</th>
</tr>
</thead>
<tbody>${tbody}</tbody>
</table>
<div class="px-3 py-2 text-xs text-gray-500 border-t bg-gray-50">${sorted.length} expense${sorted.length === 1 ? '' : 's'}</div>
</div>`;
}
export async function openNewExpense() {
await openExpenseModal({
onSaved: () => loadExpenses()
});
}
// ────────────────────────────────────────────────────────────────────
// Init / Public Entry Points
// ────────────────────────────────────────────────────────────────────
export function renderAccountingView() {
autoSyncDoneThisOpen = false;
injectToolbar();
injectRegisterControls();
injectReportsControls();
injectExpensesSection();
// Sequenz: erst Auto-Sync (wenn nötig), DANN Daten laden,
// damit das Frontend frische Cache-basierte Daten kriegt.
maybeAutoSyncCaches().then(() => {
loadAccountsOverview();
});
}
export function refreshAll() {
loadAccountsOverview();
if (registerAccountId) loadRegister();
}
window.accountingView = {
renderAccountingView,
refreshAll,
manualSync,
loadAccountsOverview,
loadRegister,
selectRegisterAccount,
loadProfitLoss,
loadBalanceSheet,
loadExpenses,
openNewExpense
};