/** * report-helpers.js — geteilte Bausteine der Report-/Accounting-Views * * Extrahiert aus accounting-view.js im Zuge der UI-Reorganisation * (Reports und Sales Tax haben eigene Tabs bekommen). Inhaltlich 1:1 * übernommen — einzige Ausnahme ist showError, das den Fehlertitel jetzt * als Parameter bekommt, damit "QBO Error" und "Report Error" beide * erhalten bleiben. * * Genutzt von: accounting-view.js, reports-view.js, salestax-view.js */ // ── Formatierung ──────────────────────────────────────────────────── /** * Accounting-Variante, um eine Number()-Koersion ergänzt. * * Das Original rief toLocaleString direkt auf dem Argument auf. Bei einer * Zahl korrekt — bei einem String greift aber String.prototype.toLocaleString, * das die Optionen ignoriert und den Rohwert zurückgibt ("19152.81" statt * "$19,152.81"). accounting-view.js übergibt ausschließlich Zahlen und war * davon nie betroffen; reports-view.js übergibt numeric-Strings vom * pg-Treiber und hätte sonst still unformatierte Beträge angezeigt. * Für Zahl-Eingaben ist die Ausgabe unverändert. */ export function fmtMoney(n) { if (n == null || n === '' || isNaN(n)) return ''; return Number(n).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }); } export function escapeHtml(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } // ── Datums-Helfer ─────────────────────────────────────────────────── export function todayISO() { return new Date().toISOString().split('T')[0]; } export function firstOfMonthISO(year, month) { const d = year != null ? new Date(year, month, 1) : new Date(); return year != null ? d.toISOString().split('T')[0] : new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split('T')[0]; } export function lastOfMonthISO(year, month) { const d = year != null ? new Date(year, month + 1, 0) : new Date(); return year != null ? d.toISOString().split('T')[0] : new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().split('T')[0]; } export function prevMonthISO() { const d = new Date(); const m = d.getMonth() === 0 ? 11 : d.getMonth() - 1; const y = d.getMonth() === 0 ? d.getFullYear() - 1 : d.getFullYear(); return `${y}-${String(m + 1).padStart(2, '0')}`; } export function firstOfYearISO() { const d = new Date(); return new Date(d.getFullYear(), 0, 1).toISOString().split('T')[0]; } // ── Statusanzeigen ────────────────────────────────────────────────── /** * @param {string} title Überschrift der Fehlerbox — "QBO Error" im * Accounting-/Sales-Tax-Kontext, "Report Error" auf dem Reports-Tab. */ export function showError(slotId, message, title = 'QBO Error') { const el = document.getElementById(slotId); if (!el) return; el.innerHTML = `

${escapeHtml(title)}

${escapeHtml(message)}

`; } export function showLoading(slotId, message = 'Loading…') { const el = document.getElementById(slotId); if (!el) return; el.innerHTML = `
${escapeHtml(message)}
`; } // ── QBO-Report-Rendering ──────────────────────────────────────────── // Wird von P&L / Balance Sheet / Sales Tax (QBO) auf dem Reports-Tab // genutzt UND vom Sales-Tax-Detaildialog auf dem Sales-Tax-Tab. export function renderQboReport(report) { if (!report || !report.Header) return `

No report data.

`; const cols = (report.Columns && report.Columns.Column) || []; const headerRow = cols.map(c => `${escapeHtml(c.ColTitle || '')}`).join(''); const body = report.Rows && report.Rows.Row ? renderReportRows(report.Rows.Row, 0) : ''; return `
${escapeHtml(report.Header.ReportName || '')} ${report.Header.StartPeriod ? '· ' + escapeHtml(report.Header.StartPeriod) + ' – ' + escapeHtml(report.Header.EndPeriod) : ''} ${report.Header.ReportBasis ? '· ' + escapeHtml(report.Header.ReportBasis) : ''}
${headerRow}${body}
`; } export 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 ? `${escapeHtml(c.value || '')}` : `` ).join(''); html += `${cells}`; } 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 ? `${escapeHtml(c.value || '')}` : `${escapeHtml(c.value || '')}` ).join(''); html += `${cells}`; } if (!isSection && row.ColData) { const cells = row.ColData.map((c, i) => i === 0 ? `${escapeHtml(c.value || '')}` : `${escapeHtml(c.value || '')}` ).join(''); html += `${cells}`; } } return html; } // ── Alpine ────────────────────────────────────────────────────────── /** * Alpine wird mit `defer` geladen und scannt das DOM nur einmal beim Start. * Views, die ihr Markup per innerHTML nachträglich einhängen, müssen den * neuen Teilbaum selbst anmelden — sonst bleiben x-data/x-show tot. * * Alpine markiert initialisierte Knoten intern; ein zweiter initTree-Lauf * über denselben Baum würde doppelte Bindings und Warnungen erzeugen. * Da innerHTML den Teilbaum jedes Mal komplett ersetzt, sind die Knoten * hier immer frisch — der Guard prüft es trotzdem, damit ein künftiger * Aufruf auf unverändertem DOM folgenlos bleibt. */ export function initAlpineTree(container) { if (!container || !window.Alpine) return; const alreadyInitialized = container.querySelector('[x-data]')?._x_dataStack; if (alreadyInitialized) return; window.Alpine.initTree(container); }