174 lines
8.2 KiB
JavaScript
174 lines
8.2 KiB
JavaScript
/**
|
||
* 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, '"').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 = `
|
||
<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);
|
||
}
|