Files
invoice-system/public/js/views/accounting-view.js
2026-05-06 15:41:02 -05:00

602 lines
26 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, read-only
*
* Drei Bereiche:
* 1) Accounts Overview — Cards mit Bank- und Credit-Card-Balances
* 2) Register — read-only Liste der Transaktionen für ein Konto
* 3) Reports — Profit & Loss + Balance Sheet
*/
import '../utils/api.js'; // ← NEU
import { formatDate } from '../utils/helpers.js';
// ────────────────────────────────────────────────────────────────────
// State (modul-lokal)
// ────────────────────────────────────────────────────────────────────
let allAccounts = []; // alle aktiven Accounts (gesamt, für Dropdown)
let registerAccountId = null; // aktuell ausgewählter Account fürs Register
let registerStartDate = null;
let registerEndDate = null;
// Reports
let plStartDate = null;
let plEndDate = null;
let plAccountingMethod = 'Accrual';
let bsAsOfDate = null;
let bsAccountingMethod = 'Accrual';
let registerLoadSeq = 0;
// ────────────────────────────────────────────────────────────────────
// 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, '&')
.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 (top-of-tab heading + sync button)
// ────────────────────────────────────────────────────────────────────
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</span>
<div class="ml-auto flex items-center gap-2">
<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>`;
}
// ────────────────────────────────────────────────────────────────────
// Accounts Overview
// ────────────────────────────────────────────────────────────────────
export async function loadAccountsOverview() {
const slot = 'accounting-accounts';
showLoading(slot, 'Loading accounts from QBO…');
try {
// Wir laden alle aktiven Accounts in einem Rutsch und filtern client-seitig.
const accounts = await window.API.accounting.getAccounts(null, true);
if (accounts.error) {
showError(slot, accounts.error);
return;
}
allAccounts = accounts;
// Bank/Credit Card Cards rendern
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 in QBO.
</div>`;
} else {
el.innerHTML = `
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${cards.map(a => renderAccountCard(a)).join('')}
</div>`;
}
// Register-Dropdown füttern (nur Bank + Credit Card)
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 bal = a.currentBalance;
const balText = bal != null ? fmtMoney(bal) : '—';
// Bei Credit Cards ist ein positiver CurrentBalance i.d.R. eine Schuld; nur Hinweis, keine Vorzeichenakrobatik.
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; // ← merken, welcher Call das ist
try {
const result = await window.API.accounting.getRegister(
registerAccountId, registerStartDate, registerEndDate
);
// Wenn inzwischen ein neuerer Call gestartet wurde → Result verwerfen
if (mySeq !== registerLoadSeq) return;
if (result.error) {
showError(slot, result.error);
return;
}
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 || [];
const meta = result.meta || {};
// ── Punkt 1: neueste Einträge oben ──
rows = rows.slice().sort((a, b) => {
const da = a.date || '';
const db = b.date || '';
if (db !== da) return db.localeCompare(da);
return 0;
});
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(r => renderRegisterRow(r)).join('');
// ── Punkt 5: dynamische Anzahl ──
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-center font-medium text-gray-700" title="Reconciled (R) / Cleared (C)">✓</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 splitCellContent = isSplit ? renderSplitCell(r) : escapeHtml(r.splitAccount || '');
// Punkt 3: Cleared-Status anzeigen mit Farbe
let clrBadge = '';
if (r.clearedStatus === 'R') {
clrBadge = `<span class="inline-block px-1.5 py-0.5 text-xs font-bold text-green-700 bg-green-100 rounded" title="Reconciled">R</span>`;
} else if (r.clearedStatus === 'C') {
clrBadge = `<span class="inline-block px-1.5 py-0.5 text-xs font-bold text-blue-700 bg-blue-100 rounded" title="Cleared">C</span>`;
}
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">${splitCellContent}</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-center">${clrBadge}</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>`;
}
// Punkt 4: Split-Aufschlüsselung
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 — P&L + Balance Sheet
// ────────────────────────────────────────────────────────────────────
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">
<!-- P&L -->
<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>
<!-- Balance Sheet -->
<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');
}
}
// ────────────────────────────────────────────────────────────────────
// Generic QBO Report Renderer (P&L + Balance Sheet)
// QBO Reports haben rekursive Section/Row-Bäume mit Summary-Zeilen.
// Wir rendern sie als verschachtelte HTML-Tabelle.
// ────────────────────────────────────────────────────────────────────
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 colCount = cols.length;
let body = '';
if (report.Rows && report.Rows.Row) {
body = renderReportRows(report.Rows.Row, 0, colCount);
}
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('');
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, colCount) {
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 indentPx = depth * 16;
if (row.Header && row.Header.ColData) {
// Section header row
const headerCells = row.Header.ColData.map((c, i) => {
if (i === 0) {
return `<td class="px-3 py-1.5 font-semibold text-gray-800" style="padding-left:${12 + indentPx}px">${escapeHtml(c.value || '')}</td>`;
}
return `<td class="px-3 py-1.5 text-right text-gray-500"></td>`;
}).join('');
html += `<tr class="bg-gray-50">${headerCells}</tr>`;
}
if (isSection && row.Rows && row.Rows.Row) {
html += renderReportRows(row.Rows.Row, depth + 1, colCount);
}
if (row.Summary && row.Summary.ColData) {
const sumCells = row.Summary.ColData.map((c, i) => {
if (i === 0) {
return `<td class="px-3 py-1.5 font-semibold text-gray-700 border-t" style="padding-left:${12 + indentPx}px">${escapeHtml(c.value || '')}</td>`;
}
return `<td class="px-3 py-1.5 text-right font-semibold text-gray-900 border-t">${escapeHtml(c.value || '')}</td>`;
}).join('');
html += `<tr>${sumCells}</tr>`;
}
if (!isSection && row.ColData) {
// Plain data row
const cells = row.ColData.map((c, i) => {
if (i === 0) {
return `<td class="px-3 py-1.5" style="padding-left:${12 + indentPx}px">${escapeHtml(c.value || '')}</td>`;
}
return `<td class="px-3 py-1.5 text-right">${escapeHtml(c.value || '')}</td>`;
}).join('');
html += `<tr>${cells}</tr>`;
}
}
return html;
}
// ────────────────────────────────────────────────────────────────────
// Init / Public Entry Points
// ────────────────────────────────────────────────────────────────────
export function renderAccountingView() {
injectToolbar();
injectRegisterControls();
injectReportsControls();
loadAccountsOverview();
}
export function refreshAll() {
loadAccountsOverview();
if (registerAccountId) loadRegister();
}
// Expose for onclick handlers
window.accountingView = {
renderAccountingView,
refreshAll,
loadAccountsOverview,
loadRegister,
selectRegisterAccount,
loadProfitLoss,
loadBalanceSheet
};