Files
invoice-system/public/js/utils/report-helpers.js
2026-07-26 16:33:55 -05:00

174 lines
8.2 KiB
JavaScript
Raw Permalink 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.
/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ── 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 = `
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="font-semibold text-red-800">${escapeHtml(title)}</p>
<p class="text-sm text-red-600 mt-1">${escapeHtml(message)}</p>
</div>`;
}
export 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>`;
}
// ── 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 `<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>`;
}
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
? `<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;
}
// ── 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);
}