fix
This commit is contained in:
@@ -280,9 +280,17 @@ function renderRegisterTable(result) {
|
||||
const el = document.getElementById('accounting-register-table');
|
||||
if (!el) return;
|
||||
|
||||
const rows = result.rows || [];
|
||||
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">
|
||||
@@ -291,27 +299,20 @@ function renderRegisterTable(result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tbody = rows.map(r => `
|
||||
<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('');
|
||||
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 text-xs text-gray-500">
|
||||
${escapeHtml(meta.reportName || 'Transaction List')}
|
||||
${meta.startPeriod ? '— ' + escapeHtml(meta.startPeriod) : ''}
|
||||
${meta.endPeriod ? ' to ' + escapeHtml(meta.endPeriod) : ''}
|
||||
· ${rows.length} rows
|
||||
<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">
|
||||
@@ -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">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>
|
||||
@@ -332,6 +334,47 @@ function renderRegisterTable(result) {
|
||||
</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
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -113,7 +113,7 @@ async function listAccounts({ type = null, activeOnly = true } = {}) {
|
||||
* @param {string} opts.startDate - 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');
|
||||
|
||||
const { companyId, baseUrl } = getClientInfo();
|
||||
@@ -121,7 +121,6 @@ async function getRegister({ accountId, startDate, endDate }) {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.set('start_date', startDate);
|
||||
if (endDate) params.set('end_date', endDate);
|
||||
// account filter: TransactionList akzeptiert eine kommaseparierte Liste
|
||||
params.set('source_account', String(accountId));
|
||||
params.set('minorversion', QBO_MINOR_VERSION);
|
||||
|
||||
@@ -131,7 +130,24 @@ async function getRegister({ accountId, startDate, endDate }) {
|
||||
const data = getJson(response);
|
||||
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 idxMemo = resolve('Memo/Description', 'Memo', 'memo');
|
||||
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) => {
|
||||
if (idx == null) return null;
|
||||
@@ -192,6 +208,7 @@ function normalizeTransactionListReport(report) {
|
||||
const memoCell = cellAt(r.ColData, idxMemo);
|
||||
const splitCell = cellAt(r.ColData, idxSplit);
|
||||
const amtCell = cellAt(r.ColData, idxAmount);
|
||||
const clrCell = cellAt(r.ColData, idxCleared);
|
||||
|
||||
const qboId =
|
||||
(dateCell && dateCell.id) ||
|
||||
@@ -199,6 +216,14 @@ function normalizeTransactionListReport(report) {
|
||||
(typeCell && typeCell.id) ||
|
||||
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({
|
||||
date: dateCell ? dateCell.value : null,
|
||||
type: typeCell ? typeCell.value : null,
|
||||
@@ -209,6 +234,7 @@ function normalizeTransactionListReport(report) {
|
||||
amount: amtCell && amtCell.value !== '' && amtCell.value != null
|
||||
? Number(amtCell.value) : null,
|
||||
splitAccount: splitCell ? splitCell.value : null,
|
||||
clearedStatus,
|
||||
qboId
|
||||
});
|
||||
}
|
||||
@@ -278,12 +304,87 @@ async function getBalanceSheet({ asOfDate, accountingMethod = 'Accrual' } = {})
|
||||
throwIfFault(data, 'BalanceSheet report');
|
||||
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 = {
|
||||
listAccounts,
|
||||
getRegister,
|
||||
getProfitAndLoss,
|
||||
getBalanceSheet,
|
||||
fetchSplitDetails,
|
||||
// exposed for testing/debugging
|
||||
normalizeTransactionListReport
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user