"use strict"; const API_URL = "https://brandize.app.n8n.cloud/webhook/bi-dashboard"; const MONTHS = ["JAN", "FEB", "MÄR", "APR", "MAI", "JUN", "JUL", "AUG", "SEP", "OKT", "NOV", "DEZ"]; const PALETTE = ["#4f46e5", "#0ea5e9", "#f59e0b", "#10b981", "#ec4899", "#8b5cf6", "#ef4444", "#14b8a6"]; const els = { month: document.getElementById("month-select"), quarter: document.getElementById("quarter-select"), year: document.getElementById("year-select"), refresh: document.getElementById("refresh-btn"), download: document.getElementById("download-btn"), // Amazon · Umsatz je Abgangsland kpis: document.getElementById("kpis"), chart: document.getElementById("chart"), legend: document.getElementById("legend"), table: document.querySelector("#data-table tbody"), tablePeriod: document.getElementById("table-period"), state: document.getElementById("state"), // Amazon · VAT Summary vatKpis: document.getElementById("vat-kpis"), vatChart: document.getElementById("vat-chart"), vatLegend: document.getElementById("vat-legend"), vatTable: document.querySelector("#vat-table tbody"), vatPeriod: document.getElementById("vat-period"), vatState: document.getElementById("vat-state"), // ODOO · Rechnungen pro Monat odooKpis: document.getElementById("odoo-kpis"), odooChart: document.getElementById("odoo-chart"), odooLegend: document.getElementById("odoo-legend"), odooPeriod: document.getElementById("odoo-period"), odooState: document.getElementById("odoo-state"), odooPivotPreset: document.getElementById("odoo-pivot-preset"), odooFilterTag: document.getElementById("odoo-filter-tag"), odooFilterBestellnummer: document.getElementById("odoo-filter-bestellnummer"), odooFilterLieferland: document.getElementById("odoo-filter-lieferland"), odooFilterNummer: document.getElementById("odoo-filter-rechnungsnummer"), // Gesamt · Alle Rechnungen pro Monat (Pivot, Experiment) pivotKpis: document.getElementById("pivot-kpis"), pivotPeriod: document.getElementById("pivot-period"), pivotState: document.getElementById("pivot-state"), pivotPreset: document.getElementById("pivot-preset"), pivotFilterTag: document.getElementById("pivot-filter-tag"), pivotFilterBestellnummer: document.getElementById("pivot-filter-bestellnummer"), pivotFilterLieferland: document.getElementById("pivot-filter-lieferland"), pivotFilterNummer: document.getElementById("pivot-filter-rechnungsnummer"), pivotFilterQuelle: document.getElementById("pivot-filter-quelle"), // Schweiz · Quartal Schweiz chKpis: document.getElementById("ch-kpis"), chPeriod: document.getElementById("ch-period"), chState: document.getElementById("ch-state"), chPreset: document.getElementById("ch-pivot-preset"), chFilterTag: document.getElementById("ch-filter-tag"), chFilterBestellnummer: document.getElementById("ch-filter-bestellnummer"), chFilterLieferland: document.getElementById("ch-filter-lieferland"), chFilterNummer: document.getElementById("ch-filter-rechnungsnummer"), chFilterQuelle: document.getElementById("ch-filter-quelle"), // Auth authOverlay: document.getElementById("auth-overlay"), authForm: document.getElementById("auth-form"), authUser: document.getElementById("auth-user"), authPass: document.getElementById("auth-pass"), authError: document.getElementById("auth-error"), authCancel: document.getElementById("auth-cancel"), }; // State for the currently displayed dashboard and its last loaded data. // Only the dataset of the active dashboard is held — nothing is prefetched. let currentView = "amazon-abgangsland"; let lastData = null; // { view, data } — data is the normalized dataset of `view` let loadToken = 0; // guards against stale responses from superseded requests /* ---------- Auth (HTTP Basic) ---------- */ const AUTH_KEY = "brandize-bi-auth"; function getStoredAuth() { try { const raw = sessionStorage.getItem(AUTH_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } function setStoredAuth(user, pass) { try { sessionStorage.setItem(AUTH_KEY, JSON.stringify({ user, pass })); } catch { /* storage unavailable (e.g. file://) — keep in memory only */ } } function clearStoredAuth() { try { sessionStorage.removeItem(AUTH_KEY); } catch { /* ignore */ } } let auth = getStoredAuth(); function base64(value) { const bytes = new TextEncoder().encode(value); let binary = ""; bytes.forEach((byte) => { binary += String.fromCharCode(byte); }); return btoa(binary); } function authHeader() { return auth ? `Basic ${base64(`${auth.user}:${auth.pass}`)}` : null; } function showAuth(message) { els.authError.textContent = message || ""; els.authError.classList.toggle("hidden", !message); els.authOverlay.classList.remove("hidden"); els.authUser.focus(); } function hideAuth() { els.authOverlay.classList.add("hidden"); } /* ---------- Period selector ---------- */ function lastMonth() { const now = new Date(); let year = now.getFullYear(); let month = now.getMonth() - 1; // 0-based, previous month if (month < 0) { month = 11; year -= 1; } return { year, month }; } function buildPeriodSelectors() { const currentYear = new Date().getFullYear(); const { year: defYear, month: defMonth } = lastMonth(); MONTHS.forEach((label, index) => { const opt = document.createElement("option"); opt.value = String(index); opt.textContent = label; els.month.appendChild(opt); }); for (let y = currentYear - 2; y <= currentYear; y++) { const opt = document.createElement("option"); opt.value = String(y); opt.textContent = String(y); els.year.appendChild(opt); } els.month.value = String(defMonth); els.quarter.value = String(Math.floor(defMonth / 3) + 1); els.year.value = String(defYear); } function selectedPeriod() { const meta = VIEWS[currentView]; if (meta && meta.quarter) { return `${els.year.value}-Q${els.quarter.value}`; } const month = MONTHS[Number(els.month.value)]; return `${els.year.value}-${month}`; } /* ---------- Formatting ---------- */ function parseEuro(value) { if (value == null) return 0; const cleaned = String(value).replace(/[^\d.,-]/g, "").replace(/\./g, "").replace(",", "."); const num = parseFloat(cleaned); return Number.isFinite(num) ? num : 0; } function parseQuantity(value) { if (value == null) return 0; const digits = String(value).replace(/[^\d]/g, ""); return digits ? parseInt(digits, 10) : 0; } function formatEuro(value) { return value.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €"; } function formatPercent(value) { return value.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + " %"; } function formatQuantity(value) { return value.toLocaleString("de-DE"); } /* ---------- Data ---------- */ function extractArray(payload, key) { // The webhook may return the requested dataset either directly as an array // or wrapped under its query key. if (Array.isArray(payload)) return payload; if (!payload || typeof payload !== "object") return []; if (Array.isArray(payload[key])) return payload[key]; const match = Object.keys(payload).find((k) => k.toLowerCase() === key.toLowerCase()); return match && Array.isArray(payload[match]) ? payload[match] : []; } function normalizeAbgangsland(rows) { const countries = []; let total = null; for (const row of rows) { const bereich = row["Bereich"] ?? ""; const land = row["Abgangsland"] ?? ""; const brutto = parseEuro(row["Brutto (inkl. USt)"]); if (bereich === "Gesamt" || land === "") { total = (total || 0) + brutto; } else { countries.push({ bereich, land, brutto }); } } countries.sort((a, b) => b.brutto - a.brutto); const sum = total != null ? total : countries.reduce((acc, c) => acc + c.brutto, 0); return { countries, total: sum }; } function normalizeVat(rows) { const countries = rows.map((row) => ({ code: row["Country Code"] ?? "", land: row["Land"] ?? "", quantity: parseQuantity(row["Quantity"]), grossTotal: parseEuro(row["Gross Sales (Total)"]), grossNonOss: parseEuro(row["Gross Sales (Non-OSS)"]), vatToRemit: parseEuro(row["VAT to Remit (Non-OSS)"]), grossExclB2b: parseEuro(row["Gross Sales (Excl. B2B)"]), intraSupply: parseEuro(row["Intra-community supply – Net sales"]), })); countries.sort((a, b) => b.grossTotal - a.grossTotal); const totals = countries.reduce( (acc, c) => { acc.quantity += c.quantity; acc.grossTotal += c.grossTotal; acc.grossNonOss += c.grossNonOss; acc.vatToRemit += c.vatToRemit; acc.grossExclB2b += c.grossExclB2b; acc.intraSupply += c.intraSupply; return acc; }, { quantity: 0, grossTotal: 0, grossNonOss: 0, vatToRemit: 0, grossExclB2b: 0, intraSupply: 0 } ); return { countries, totals }; } // Amounts may arrive either as plain numbers (SQL numeric) or as German // formatted strings ("90.717,93 €") — accept both. function parseAmount(value) { if (typeof value === "number") return Number.isFinite(value) ? value : 0; return parseEuro(value); } function computeRechnungenTotals(invoices) { return invoices.reduce( (acc, i) => { acc.gesamtBrutto += i.gesamtBrutto; acc.gesamtNetto += i.gesamtNetto; acc.brutto += i.brutto; acc.bruttoErm += i.bruttoErm; acc.netto += i.netto; acc.nettoErm += i.nettoErm; acc.versandBrutto += i.versandBrutto; acc.versandNetto += i.versandNetto; return acc; }, { gesamtBrutto: 0, gesamtNetto: 0, brutto: 0, bruttoErm: 0, netto: 0, nettoErm: 0, versandBrutto: 0, versandNetto: 0 } ); } function normalizeRechnungen(rows) { const invoices = rows.map((row) => ({ datum: row["Rechnungsdatum"] ?? "", nummer: row["Rechnungsnummer"] ?? "", land: row["Rechnungsland"] ?? "", landCode: row["Rechnungsland Ländercode"] ?? "", zahlart: row["Zahlart"] ?? "", zahlungsreferenz: row["Zahlungsreference der Bestellung"] ?? "", vatId: row["USt. Id / VAT Id des Käufers (in Bestellung)"] ?? "", gesamtBrutto: parseAmount(row["Gesamtpreis brutto"]), gesamtNetto: parseAmount(row["Gesamtpreis netto"]), brutto: parseAmount(row["Brutto"]), bruttoErm: parseAmount(row["Brutto erm"]), ustSatz: parseAmount(row["USt. Satz"]), ustSatzErm: parseAmount(row["Ust. Satz erm"]), netto: parseAmount(row["Netto Betrag"]), nettoErm: parseAmount(row["Netto Betrag erm"]), versandBrutto: parseAmount(row["Versandkosten brutto"]), versandNetto: parseAmount(row["Versandkosten netto"]), auftragsdatum: row["Auftragsdatum"] ?? "", bestellnummer: row["Bestellnummer"] ?? "", zahldatum: row["Zahldatum"] ?? "", lieferName: row["Lieferadresse Name"] ?? "", tag: row["Tag"] ?? "", lieferland: row["Lieferland"] ?? "", quelle: row["Quelle"] ?? "", })); return { invoices, totals: computeRechnungenTotals(invoices) }; } /* ---------- ODOO Rechnungen pro Monat: filters ---------- */ let rechnungenFilter = { tag: "", bestellnummer: "", lieferland: "", nummer: "", quelle: "" }; function matchesFilter(value, query) { if (!query) return true; return String(value).toLowerCase().includes(query.toLowerCase()); } function applyRechnungenFilters(data) { const f = rechnungenFilter; const invoices = data.invoices.filter((i) => matchesFilter(i.tag, f.tag) && matchesFilter(i.bestellnummer, f.bestellnummer) && matchesFilter(i.lieferland, f.lieferland) && matchesFilter(i.nummer, f.nummer) && matchesFilter(i.quelle, f.quelle) ); return { invoices, totals: computeRechnungenTotals(invoices) }; } function rechnungenFilterInputs() { if (currentView === "gesamt-rechnungen-pivot") { return [els.pivotFilterTag, els.pivotFilterBestellnummer, els.pivotFilterLieferland, els.pivotFilterNummer, els.pivotFilterQuelle]; } if (currentView === "schweiz-quartal") { return [els.chFilterTag, els.chFilterBestellnummer, els.chFilterLieferland, els.chFilterNummer, els.chFilterQuelle]; } if (currentView === "odoo-rechnungen") { return [els.odooFilterTag, els.odooFilterBestellnummer, els.odooFilterLieferland, els.odooFilterNummer]; } return []; } // Every filter input across the invoice dashboards. Attached once at startup, // because the views are only toggled — the inputs stay in the DOM. function allRechnungenFilterInputs() { return [ els.odooFilterTag, els.odooFilterBestellnummer, els.odooFilterLieferland, els.odooFilterNummer, els.pivotFilterTag, els.pivotFilterBestellnummer, els.pivotFilterLieferland, els.pivotFilterNummer, els.pivotFilterQuelle, els.chFilterTag, els.chFilterBestellnummer, els.chFilterLieferland, els.chFilterNummer, els.chFilterQuelle, ]; } function resetRechnungenFilters() { rechnungenFilter = { tag: "", bestellnummer: "", lieferland: "", nummer: "", quelle: "" }; rechnungenFilterInputs().forEach((input) => { if (input) input.value = ""; }); } function onRechnungenFilter() { const [tag, bestellnummer, lieferland, nummer, quelle] = rechnungenFilterInputs(); rechnungenFilter = { tag: (tag?.value || "").trim(), bestellnummer: (bestellnummer?.value || "").trim(), lieferland: (lieferland?.value || "").trim(), nummer: (nummer?.value || "").trim(), quelle: (quelle?.value || "").trim(), }; if (lastData && lastData.view === currentView) { renderActiveView(); } } function setupRechnungenFilters() { allRechnungenFilterInputs().forEach((input) => { if (input) input.addEventListener("input", onRechnungenFilter); }); } function setupRechnungenPivot() { wirePivotPreset(els.odooPivotPreset, "odoo-rechnungen"); wirePivotPreset(els.pivotPreset, "gesamt-rechnungen-pivot"); wirePivotPreset(els.chPreset, "schweiz-quartal"); } function wirePivotPreset(select, view) { if (!select) return; select.addEventListener("change", () => { const presets = PIVOT_PRESETS[view]; const preset = presets[select.value] || presets.standard; pivotOptions[view] = { rows: [...preset.rows], cols: [...preset.cols], vals: [...preset.vals], aggregatorName: preset.aggregatorName, rendererName: preset.rendererName, }; // A preset also loads the default (empty) filters. resetRechnungenFilters(); renderActiveView(); }); } /* ---------- Rendering: Umsatz je Abgangsland ---------- */ function colorFor(bereich, map) { if (!map.has(bereich)) map.set(bereich, PALETTE[map.size % PALETTE.length]); return map.get(bereich); } function renderAbgangslandKpis(data) { const { countries, total } = data; const top = countries[0]; const avg = countries.length ? total / countries.length : 0; els.kpis.innerHTML = `
Keine Daten für diesen Zeitraum.
'; return; } countries.forEach((c) => { const color = colorFor(c.bereich, colorMap); const pct = (c.brutto / max) * 100; const share = total ? (c.brutto / total) * 100 : 0; const row = document.createElement("div"); row.className = "bar-row"; row.innerHTML = ` `; els.chart.appendChild(row); const fill = row.querySelector(".bar-fill"); // Force a reflow so the width transition animates from 0 to the target. fill.getBoundingClientRect(); fill.style.width = Math.max(pct, 6) + "%"; fill.classList.add("ready"); }); colorMap.forEach((color, bereich) => { const item = document.createElement("div"); item.className = "legend-item"; item.innerHTML = `${bereich}`; els.legend.appendChild(item); }); } function renderAbgangslandTable(data) { const { countries, total } = data; els.table.innerHTML = ""; countries.forEach((c) => { const share = total ? (c.brutto / total) * 100 : 0; const tr = document.createElement("tr"); tr.innerHTML = `Keine Daten für diesen Zeitraum.
'; return; } countries.forEach((c) => { const pct = (c.grossTotal / max) * 100; const share = totals.grossTotal ? (c.grossTotal / totals.grossTotal) * 100 : 0; const row = document.createElement("div"); row.className = "bar-row"; row.innerHTML = ` `; els.vatChart.appendChild(row); const fill = row.querySelector(".bar-fill"); fill.getBoundingClientRect(); fill.style.width = Math.max(pct, 6) + "%"; fill.classList.add("ready"); }); } function renderVatTable(data) { const { countries, totals } = data; els.vatTable.innerHTML = ""; countries.forEach((c) => { const tr = document.createElement("tr"); tr.innerHTML = `Keine Daten für diesen Zeitraum.
'; return; } entries.forEach(([code, value]) => { const pct = (value / max) * 100; const share = grand ? (value / grand) * 100 : 0; const row = document.createElement("div"); row.className = "bar-row"; row.innerHTML = ` `; v.chart.appendChild(row); const fill = row.querySelector(".bar-fill"); fill.getBoundingClientRect(); fill.style.width = Math.max(pct, 6) + "%"; fill.classList.add("ready"); }); } /* ---------- Rechnungen pivot (experiment) ---------- */ // Preset pivot layouts per view, selectable from each view's "Vorlage" dropdown. const PIVOT_PRESETS = { "gesamt-rechnungen-pivot": { standard: { rows: ["Quelle"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" }, rechnungsland: { rows: ["Quelle"], cols: ["Rechnungsland"], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" }, }, "odoo-rechnungen": { standard: { rows: ["Rechnungsland"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" }, zahlart: { rows: ["Zahlart"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" }, }, "schweiz-quartal": { standard: { rows: ["Quelle"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" }, rechnungsland: { rows: ["Quelle"], cols: ["Rechnungsland"], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" }, }, }; // Current pivot layout per view (kept so a filter re-render doesn't reset it). const pivotOptions = { "gesamt-rechnungen-pivot": { ...PIVOT_PRESETS["gesamt-rechnungen-pivot"].standard }, "odoo-rechnungen": { ...PIVOT_PRESETS["odoo-rechnungen"].standard }, "schweiz-quartal": { ...PIVOT_PRESETS["schweiz-quartal"].standard }, }; function renderRechnungenPivot(data) { const v = viewEls(); if (!v || !v.pivot) return; renderRechnungenKpis(data); if (v.chart) renderRechnungenChart(data); // Recreate the container so pivotUI cannot reuse state from a previous // render — clearing innerHTML alone is not enough, so a preset change would // not take effect. const prev = v.pivot; const el = prev.cloneNode(false); prev.parentNode.replaceChild(el, prev); if (!window.jQuery || !jQuery.fn.pivotUI) { el.innerHTML = 'PivotTable.js ist nicht geladen (offline?).
'; return; } if (!data.invoices.length) { el.innerHTML = 'Keine Daten für diesen Zeitraum.
'; return; } // Flaches Datenmodell für die Pivot-Tabelle. Alle Attribute sind im // pivotUI-Bedienfeld frei kombinierbar (Zeilen/Spalten/Werte/Aggregator). // Hinweis: "Brutto" ist nur der Normalsatz-Anteil; "Gesamtpreis brutto" ist // Brutto + Brutto erm (= die Summe, die die KPI "Gesamt brutto" anzeigt). const rows = data.invoices.map((i) => ({ Quelle: i.quelle || "(leer)", Rechnungsland: i.land || "(leer)", Zahlart: i.zahlart || "(leer)", Bestellnummer: i.bestellnummer || "(leer)", Tag: i.tag || "(leer)", Lieferland: i.lieferland || "(leer)", "Gesamtpreis brutto": i.gesamtBrutto, Brutto: i.brutto, "Brutto erm": i.bruttoErm, "Gesamtpreis netto": i.gesamtNetto, Netto: i.netto, "Netto erm": i.nettoErm, "Versandkosten brutto": i.versandBrutto, "Versandkosten netto": i.versandNetto, })); const opts = { ...pivotOptions[currentView], unusedAttrsVertical: true, onRefresh: (config) => { pivotOptions[currentView] = { rows: config.rows, cols: config.cols, vals: config.vals, aggregatorName: config.aggregatorName, rendererName: config.rendererName, }; }, }; // Only offer the plain table renderer (no heatmaps/charts). const tableRenderer = jQuery.pivotUtilities && jQuery.pivotUtilities.renderers ? jQuery.pivotUtilities.renderers["Table"] : null; if (tableRenderer) opts.renderers = { Table: tableRenderer }; jQuery(el).pivotUI(rows, opts); } /* ---------- Active view helpers ---------- */ function viewEls() { if (currentView === "amazon-abgangsland") { return { kpis: els.kpis, chart: els.chart, legend: els.legend, table: els.table, state: els.state }; } if (currentView === "amazon-vat-summary") { return { kpis: els.vatKpis, chart: els.vatChart, legend: els.vatLegend, table: els.vatTable, state: els.vatState }; } if (currentView === "odoo-rechnungen") { return { kpis: els.odooKpis, chart: els.odooChart, legend: els.odooLegend, state: els.odooState, pivot: document.getElementById("odoo-pivot") }; } if (currentView === "gesamt-rechnungen-pivot") { return { kpis: els.pivotKpis, state: els.pivotState, pivot: document.getElementById("pivot") }; } if (currentView === "schweiz-quartal") { return { kpis: els.chKpis, state: els.chState, pivot: document.getElementById("ch-pivot") }; } return null; } function renderActiveView() { if (!lastData || lastData.view !== currentView) return; if (currentView === "amazon-abgangsland") { renderAbgangsland(lastData.data); } else if (currentView === "amazon-vat-summary") { renderVat(lastData.data); } else if (currentView === "odoo-rechnungen" || currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal") { renderRechnungenPivot(applyRechnungenFilters(lastData.data)); } } /* ---------- Loading ---------- */ function skeletonKpis() { return Array.from({ length: 4 }) .map(() => '