This commit is contained in:
2026-05-06 15:41:02 -05:00
parent 1ea750d001
commit 1680ca1b56
2 changed files with 167 additions and 23 deletions

View File

@@ -280,9 +280,17 @@ function renderRegisterTable(result) {
const el = document.getElementById('accounting-register-table'); const el = document.getElementById('accounting-register-table');
if (!el) return; if (!el) return;
const rows = result.rows || []; let rows = result.rows || [];
const meta = result.meta || {}; 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) { if (!rows.length) {
el.innerHTML = ` el.innerHTML = `
<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg text-gray-600"> <div class="p-4 bg-gray-50 border border-gray-200 rounded-lg text-gray-600">
@@ -291,27 +299,20 @@ function renderRegisterTable(result) {
return; return;
} }
const tbody = rows.map(r => ` const tbody = rows.map(r => renderRegisterRow(r)).join('');
<tr class="border-t hover:bg-gray-50">
<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">${escapeHtml(r.splitAccount || '')}</td>
<td class="px-3 py-2 text-sm text-gray-500" title="${escapeHtml(r.memo || '')}">${escapeHtml((r.memo || '').slice(0, 60))}</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>
`).join('');
// ── Punkt 5: dynamische Anzahl ──
el.innerHTML = ` el.innerHTML = `
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"> <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 text-xs text-gray-500"> <div class="px-4 py-2 border-b bg-gray-50 flex items-center justify-between">
${escapeHtml(meta.reportName || 'Transaction List')} <div class="text-xs text-gray-500">
${meta.startPeriod ? '— ' + escapeHtml(meta.startPeriod) : ''} ${escapeHtml(meta.reportName || 'Transaction List')}
${meta.endPeriod ? ' to ' + escapeHtml(meta.endPeriod) : ''} ${meta.startPeriod ? ' ' + escapeHtml(meta.startPeriod) : ''}
· ${rows.length} rows ${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>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full text-sm"> <table class="min-w-full text-sm">
@@ -323,6 +324,7 @@ function renderRegisterTable(result) {
<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">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">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-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> <th class="px-3 py-2 text-right font-medium text-gray-700">Amount</th>
</tr> </tr>
</thead> </thead>
@@ -332,6 +334,47 @@ function renderRegisterTable(result) {
</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 // Reports — P&L + Balance Sheet
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────

View File

@@ -113,7 +113,7 @@ async function listAccounts({ type = null, activeOnly = true } = {}) {
* @param {string} opts.startDate - YYYY-MM-DD * @param {string} opts.startDate - YYYY-MM-DD
* @param {string} opts.endDate - YYYY-MM-DD * @param {string} opts.endDate - YYYY-MM-DD
*/ */
async function getRegister({ accountId, startDate, endDate }) { async function getRegister({ accountId, startDate, endDate, includeSplits = true }) {
if (!accountId) throw new Error('accountId is required'); if (!accountId) throw new Error('accountId is required');
const { companyId, baseUrl } = getClientInfo(); const { companyId, baseUrl } = getClientInfo();
@@ -121,7 +121,6 @@ async function getRegister({ accountId, startDate, endDate }) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (startDate) params.set('start_date', startDate); if (startDate) params.set('start_date', startDate);
if (endDate) params.set('end_date', endDate); if (endDate) params.set('end_date', endDate);
// account filter: TransactionList akzeptiert eine kommaseparierte Liste
params.set('source_account', String(accountId)); params.set('source_account', String(accountId));
params.set('minorversion', QBO_MINOR_VERSION); params.set('minorversion', QBO_MINOR_VERSION);
@@ -131,7 +130,24 @@ async function getRegister({ accountId, startDate, endDate }) {
const data = getJson(response); const data = getJson(response);
throwIfFault(data, 'TransactionList report'); throwIfFault(data, 'TransactionList report');
return normalizeTransactionListReport(data); const result = normalizeTransactionListReport(data);
// ── NEU: Split-Details nachladen ──
if (includeSplits) {
const splitRows = result.rows.filter(r =>
r.splitAccount === '-Split-' && r.qboId
);
if (splitRows.length) {
const splits = await fetchSplitDetails(splitRows);
result.rows.forEach(r => {
if (r.qboId && splits[r.qboId]) {
r.splits = splits[r.qboId];
}
});
}
}
return result;
} }
/** /**
@@ -164,7 +180,7 @@ function normalizeTransactionListReport(report) {
const idxAccount = resolve('Account', 'account_name'); const idxAccount = resolve('Account', 'account_name');
const idxMemo = resolve('Memo/Description', 'Memo', 'memo'); const idxMemo = resolve('Memo/Description', 'Memo', 'memo');
const idxSplit = resolve('Split', 'split_acc'); const idxSplit = resolve('Split', 'split_acc');
const idxAmount = resolve('Amount', 'subt_nat_amount', 'subt_nat_home_amount'); const idxCleared = resolve('Cleared', 'cleared_status', 'clr');
const cellAt = (colData, idx) => { const cellAt = (colData, idx) => {
if (idx == null) return null; if (idx == null) return null;
@@ -192,6 +208,7 @@ function normalizeTransactionListReport(report) {
const memoCell = cellAt(r.ColData, idxMemo); const memoCell = cellAt(r.ColData, idxMemo);
const splitCell = cellAt(r.ColData, idxSplit); const splitCell = cellAt(r.ColData, idxSplit);
const amtCell = cellAt(r.ColData, idxAmount); const amtCell = cellAt(r.ColData, idxAmount);
const clrCell = cellAt(r.ColData, idxCleared);
const qboId = const qboId =
(dateCell && dateCell.id) || (dateCell && dateCell.id) ||
@@ -199,6 +216,14 @@ function normalizeTransactionListReport(report) {
(typeCell && typeCell.id) || (typeCell && typeCell.id) ||
null; null;
// ── NEU: Cleared-Status normalisieren ──
// QBO liefert "R" (reconciled), "C" (cleared) oder leer (uncleared)
let clearedStatus = null;
if (clrCell && clrCell.value) {
const v = String(clrCell.value).trim().toUpperCase();
if (v === 'R' || v === 'C') clearedStatus = v;
}
rows.push({ rows.push({
date: dateCell ? dateCell.value : null, date: dateCell ? dateCell.value : null,
type: typeCell ? typeCell.value : null, type: typeCell ? typeCell.value : null,
@@ -209,6 +234,7 @@ function normalizeTransactionListReport(report) {
amount: amtCell && amtCell.value !== '' && amtCell.value != null amount: amtCell && amtCell.value !== '' && amtCell.value != null
? Number(amtCell.value) : null, ? Number(amtCell.value) : null,
splitAccount: splitCell ? splitCell.value : null, splitAccount: splitCell ? splitCell.value : null,
clearedStatus,
qboId qboId
}); });
} }
@@ -278,12 +304,87 @@ async function getBalanceSheet({ asOfDate, accountingMethod = 'Accrual' } = {})
throwIfFault(data, 'BalanceSheet report'); throwIfFault(data, 'BalanceSheet report');
return data; return data;
} }
/**
* Lädt für eine Liste von Split-Transaktionen die Einzel-Lines aus QBO.
* Wir laden nur das, was als Split markiert ist und einen qboId hat.
*
* Returns: Map<qboId, Array<{account, amount, description}>>
*/
async function fetchSplitDetails(splitRows) {
if (!splitRows || splitRows.length === 0) return {};
const { companyId, baseUrl } = getClientInfo();
// Group by Type, weil QBO unterschiedliche Endpoints für Purchase/Deposit/JournalEntry hat
const result = {};
for (const row of splitRows) {
if (!row.qboId) continue;
// Type → QBO endpoint name
const endpoint = mapTypeToEndpoint(row.type);
if (!endpoint) continue;
try {
const url = withMinorVersion(
`${baseUrl}/v3/company/${companyId}/${endpoint}/${row.qboId}`
);
const response = await makeQboApiCall({ url, method: 'GET' });
const data = getJson(response);
// Response shape: { Purchase: {...} } or { Deposit: {...} } etc.
const txn = data[capitalize(endpoint)] || data[endpoint];
if (!txn || !txn.Line) continue;
const lines = txn.Line
.filter(l => l.DetailType !== 'SubTotalLineDetail')
.map(l => {
const detail = l.AccountBasedExpenseLineDetail
|| l.DepositLineDetail
|| l.JournalEntryLineDetail
|| {};
const acctRef = detail.AccountRef || {};
return {
account: acctRef.name || null,
amount: l.Amount != null ? Number(l.Amount) : null,
description: l.Description || null
};
})
.filter(l => l.account || l.amount != null);
if (lines.length) result[row.qboId] = lines;
} catch (err) {
// Einzelne Fehler nicht den ganzen Register killen lassen
console.warn(`Split detail fetch failed for ${row.type} ${row.qboId}:`, err.message);
}
}
return result;
}
function mapTypeToEndpoint(type) {
if (!type) return null;
const t = type.toLowerCase();
if (t.includes('expense') || t.includes('check')) return 'purchase';
if (t.includes('deposit')) return 'deposit';
if (t.includes('journal')) return 'journalentry';
if (t.includes('bill payment')) return 'billpayment';
if (t.includes('bill')) return 'bill';
if (t.includes('credit card')) return 'purchase';
if (t.includes('paycheck') || t.includes('payroll')) return null; // QBO blockt Paycheck-API
if (t.includes('tax payment')) return null; // dito
return null;
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
module.exports = { module.exports = {
listAccounts, listAccounts,
getRegister, getRegister,
getProfitAndLoss, getProfitAndLoss,
getBalanceSheet, getBalanceSheet,
fetchSplitDetails,
// exposed for testing/debugging // exposed for testing/debugging
normalizeTransactionListReport normalizeTransactionListReport
}; };