move reports
This commit is contained in:
173
public/js/utils/report-helpers.js
Normal file
173
public/js/utils/report-helpers.js
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user