"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"), // ODOO · FIFO Lager Bewertung fifoKpis: document.getElementById("fifo-kpis"), fifoTable: document.querySelector("#fifo-table tbody"), fifoInfo: document.getElementById("fifo-info"), fifoState: document.getElementById("fifo-state"), // ODOO · Produkt Materialkosten mkTable: document.querySelector("#mk-table tbody"), mkInfo: document.getElementById("mk-info"), mkState: document.getElementById("mk-state"), // 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"); } function formatDate(value) { return value ? String(value).slice(0, 10) : ""; } /* ---------- 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) }; } function pickLocalized(value) { if (value == null) return ""; if (typeof value !== "object") return String(value); return value.de_DE ?? value.de_CH ?? value.en_US ?? Object.values(value)[0] ?? ""; } function normalizeFifo(rows) { // The webhook returns [{ id, query, row_count, data: [...] }]. let items = Array.isArray(rows) ? rows : []; if (items.length && items[0] && Array.isArray(items[0].data)) { items = items.flatMap((r) => (Array.isArray(r.data) ? r.data : [])); } const products = items.map((row) => { const unitCost = parseAmount(row.unit_cost); const qty = parseAmount(row.remaining_qty); return { productId: row.product_id, code: row.default_code ?? "", name: pickLocalized(row.product_name), categId: row.categ_id, unitCost, qty, value: unitCost * qty, }; }); const totals = products.reduce( (acc, p) => { acc.value += p.value; acc.qty += p.qty; return acc; }, { value: 0, qty: 0 } ); return { products, totals, productCount: new Set(products.map((p) => p.productId)).size }; } function normalizeMaterialkosten(rows) { // Response: [{ id, query, row_count, data: [...] }], one row per product. // remaining_qty = remaining quantity of that cost bracket, NOT the total. let items = Array.isArray(rows) ? rows : []; if (items.length && items[0] && Array.isArray(items[0].data)) { items = items.flatMap((r) => (Array.isArray(r.data) ? r.data : [])); } const products = items.map((row) => { const unitCost = parseAmount(row.unit_cost); const qty = parseAmount(row.remaining_qty); return { productId: row.product_id, code: row.default_code ?? "", name: pickLocalized(row.product_name), categId: row.categ_id, unitCost, qty, value: unitCost * qty, createDate: formatDate(row.create_date), }; }); // Sort by category, then product name. products.sort((a, b) => { const ca = Number(a.categId) || 0; const cb = Number(b.categId) || 0; if (ca !== cb) return ca - cb; return String(a.name).localeCompare(String(b.name), "de"); }); const totals = products.reduce( (acc, p) => { acc.value += p.value; acc.qty += p.qty; return acc; }, { value: 0, qty: 0 } ); return { products, totals, productCount: new Set(products.map((p) => p.productId)).size }; } /* ---------- 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 = `
Gesamtumsatz
${formatEuro(total)}
Brutto inkl. USt
Abgangsländer
${countries.length}
mit Umsatz im Zeitraum
Stärkstes Land
${top ? top.land : "–"}
${top ? formatEuro(top.brutto) : "keine Daten"}
Ø je Land
${formatEuro(avg)}
Durchschnitt
`; } function renderAbgangslandChart(data) { const { countries, total } = data; const colorMap = new Map(); const max = countries.reduce((m, c) => Math.max(m, c.brutto), 0) || 1; els.chart.innerHTML = ""; els.legend.innerHTML = ""; if (!countries.length) { els.chart.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 = `
${c.land}
${formatEuro(c.brutto)} · ${formatPercent(share)}
`; 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 = ` ${c.bereich} ${c.land} ${formatEuro(c.brutto)} ${formatPercent(share)} `; els.table.appendChild(tr); }); const tr = document.createElement("tr"); tr.className = "total"; tr.innerHTML = ` Gesamt ${formatEuro(total)} 100,0 %`; els.table.appendChild(tr); } function renderAbgangsland(data) { renderAbgangslandKpis(data); renderAbgangslandChart(data); renderAbgangslandTable(data); } /* ---------- Rendering: VAT Summary ---------- */ function renderVatKpis(data) { const { countries, totals } = data; els.vatKpis.innerHTML = `
Gross Sales (Total)
${formatEuro(totals.grossTotal)}
Summe aller Länder
VAT to Remit (Non-OSS)
${formatEuro(totals.vatToRemit)}
abzuführende USt
Länder
${countries.length}
mit Umsatz im Zeitraum
Menge
${formatQuantity(totals.quantity)}
Artikel gesamt
`; } function renderVatChart(data) { const { countries, totals } = data; const max = countries.reduce((m, c) => Math.max(m, c.grossTotal), 0) || 1; els.vatChart.innerHTML = ""; els.vatLegend.innerHTML = ""; if (!countries.length) { els.vatChart.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 = `
${c.code}
${formatEuro(c.grossTotal)} · ${formatPercent(share)}
`; 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 = ` ${c.code} ${c.land} ${formatQuantity(c.quantity)} ${formatEuro(c.grossTotal)} ${formatEuro(c.grossNonOss)} ${formatEuro(c.vatToRemit)} ${formatEuro(c.grossExclB2b)} ${formatEuro(c.intraSupply)}`; els.vatTable.appendChild(tr); }); const tr = document.createElement("tr"); tr.className = "total"; tr.innerHTML = ` Gesamt ${formatQuantity(totals.quantity)} ${formatEuro(totals.grossTotal)} ${formatEuro(totals.grossNonOss)} ${formatEuro(totals.vatToRemit)} ${formatEuro(totals.grossExclB2b)} ${formatEuro(totals.intraSupply)}`; els.vatTable.appendChild(tr); } function renderVat(data) { renderVatKpis(data); renderVatChart(data); renderVatTable(data); } /* ---------- Rendering: ODOO Rechnungen pro Monat ---------- */ function renderRechnungenKpis(data) { const v = viewEls(); if (!v) return; const { invoices, totals } = data; const ust = totals.gesamtBrutto - totals.gesamtNetto; v.kpis.innerHTML = `
Gesamt brutto
${formatEuro(totals.gesamtBrutto)}
alle Rechnungen im Zeitraum
Rechnungen
${invoices.length}
Datensätze
Gesamt netto
${formatEuro(totals.gesamtNetto)}
ohne USt
USt (brutto − netto)
${formatEuro(ust)}
enthaltene Umsatzsteuer
`; } function renderFifo(data) { const v = viewEls(); if (!v) return; const { products, totals, productCount } = data; v.kpis.innerHTML = `
Lagerwert (FIFO)
${formatEuro(totals.value)}
Summe Stückkosten × Restmenge
Produkte
${productCount}
verschiedene Artikel
Menge
${formatQuantity(totals.qty)}
Restmenge gesamt
Positionen
${products.length}
FIFO-Schichten
`; if (els.fifoInfo) els.fifoInfo.textContent = `${products.length} Positionen`; v.table.innerHTML = ""; products.forEach((p) => { const tr = document.createElement("tr"); tr.innerHTML = ` ${p.name} ${p.code} ${p.categId ?? ""} ${formatEuro(p.unitCost)} ${formatQuantity(p.qty)} ${formatEuro(p.value)}`; v.table.appendChild(tr); }); const tr = document.createElement("tr"); tr.className = "total"; tr.innerHTML = ` Gesamt ${formatQuantity(totals.qty)} ${formatEuro(totals.value)}`; v.table.appendChild(tr); } function renderMaterialkosten(data) { const v = viewEls(); if (!v) return; const { products, totals } = data; if (els.mkInfo) els.mkInfo.textContent = `${products.length} Positionen`; v.table.innerHTML = ""; products.forEach((p) => { const tr = document.createElement("tr"); tr.innerHTML = ` ${p.name} ${p.categId ?? ""} ${p.createDate} ${formatEuro(p.unitCost)} ${formatQuantity(p.qty)}`; v.table.appendChild(tr); }); const tr = document.createElement("tr"); tr.className = "total"; tr.innerHTML = ` Gesamt ${formatQuantity(totals.qty)}`; v.table.appendChild(tr); } function renderRechnungenChart(data) { const v = viewEls(); if (!v || !v.chart) return; const byCountry = new Map(); data.invoices.forEach((inv) => { const key = inv.landCode || inv.land || "–"; byCountry.set(key, (byCountry.get(key) || 0) + inv.gesamtBrutto); }); const entries = [...byCountry.entries()].sort((a, b) => b[1] - a[1]); const grand = entries.reduce((sum, [, value]) => sum + value, 0); const max = entries.reduce((m, [, value]) => Math.max(m, value), 0) || 1; v.chart.innerHTML = ""; v.legend.innerHTML = ""; if (!entries.length) { v.chart.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 = `
${code}
${formatEuro(value)} · ${formatPercent(share)}
`; 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 === "fifo-lager") { return { kpis: els.fifoKpis, table: els.fifoTable, state: els.fifoState }; } if (currentView === "materialkosten") { return { table: els.mkTable, state: els.mkState }; } 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)); } else if (currentView === "fifo-lager") { renderFifo(lastData.data); } else if (currentView === "materialkosten") { renderMaterialkosten(lastData.data); } } /* ---------- Loading ---------- */ function skeletonKpis() { return Array.from({ length: 4 }) .map(() => '
') .join(""); } function skeletonBars() { return Array.from({ length: 5 }).map(() => '
').join(""); } function showSkeleton() { const v = viewEls(); if (!v) return; v.state.classList.add("hidden"); if (v.kpis) v.kpis.innerHTML = skeletonKpis(); if (v.chart) v.chart.innerHTML = skeletonBars(); if (v.legend) v.legend.innerHTML = ""; if (v.table) v.table.innerHTML = ""; if (v.pivot) v.pivot.innerHTML = ""; } function showError(message) { const v = viewEls(); if (!v) return; v.state.className = "state error"; v.state.textContent = message; } function clearViews() { const v = viewEls(); if (!v) return; if (v.kpis) v.kpis.innerHTML = ""; if (v.chart) v.chart.innerHTML = ""; if (v.legend) v.legend.innerHTML = ""; if (v.table) v.table.innerHTML = ""; if (v.pivot) v.pivot.innerHTML = ""; } async function load() { const meta = VIEWS[currentView]; const query = meta && meta.query; // The datasets are huge: only the dataset of the displayed dashboard is // requested, and only when that dashboard is shown or reloaded. if (!query) { lastData = null; clearViews(); updateDownloadState(); return; } resetRechnungenFilters(); const period = selectedPeriod(); els.tablePeriod.textContent = period; els.vatPeriod.textContent = period; els.odooPeriod.textContent = period; els.pivotPeriod.textContent = period; els.chPeriod.textContent = period; els.refresh.classList.add("spinning"); showSkeleton(); const token = ++loadToken; try { const headers = { Accept: "application/json", "Content-Type": "application/json" }; const authorization = authHeader(); if (authorization) headers.Authorization = authorization; const body = meta.quarter ? { query, quarter: period } : { query, month: period }; const response = await fetch(API_URL, { method: "POST", headers, body: JSON.stringify(body), }); // A newer request (view/period switch) superseded this one — ignore it. if (token !== loadToken) return; // The webhook is protected with Basic Auth: ask for credentials when needed. if (response.status === 401) { lastData = null; clearStoredAuth(); auth = null; clearViews(); const v = viewEls(); if (v) v.state.className = "state hidden"; showAuth("Bitte Zugangsdaten eingeben."); return; } if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); lastData = { view: currentView, data: meta.normalize(extractArray(payload, query)) }; renderActiveView(); const v = viewEls(); if (v) { v.state.className = "state hidden"; v.state.textContent = ""; } } catch (error) { if (token !== loadToken) return; lastData = null; clearViews(); showError(`Daten konnten nicht geladen werden: ${error.message}`); } finally { els.refresh.classList.remove("spinning"); updateDownloadState(); } } /* ---------- CSV export ---------- */ function csvEscape(value) { return `"${String(value ?? "").replace(/"/g, '""')}"`; } function buildAbgangslandCsv(data) { const lines = [["Bereich", "Abgangsland", "Brutto (inkl. USt)", "Anteil"].map(csvEscape).join(";")]; data.countries.forEach((c) => { const share = data.total ? (c.brutto / data.total) * 100 : 0; lines.push([c.bereich, c.land, formatEuro(c.brutto), formatPercent(share)].map(csvEscape).join(";")); }); lines.push(["Gesamt", "", formatEuro(data.total), "100,0 %"].map(csvEscape).join(";")); return lines.join("\r\n"); } function buildVatCsv(data) { const header = [ "Country Code", "Land", "Quantity", "Gross Sales (Total)", "Gross Sales (Non-OSS)", "VAT to Remit (Non-OSS)", "Gross Sales (Excl. B2B)", "Intra-community supply – Net sales", ]; const lines = [header.map(csvEscape).join(";")]; data.countries.forEach((c) => { lines.push([ c.code, c.land, formatQuantity(c.quantity), formatEuro(c.grossTotal), formatEuro(c.grossNonOss), formatEuro(c.vatToRemit), formatEuro(c.grossExclB2b), formatEuro(c.intraSupply), ].map(csvEscape).join(";")); }); const t = data.totals; lines.push([ "Gesamt", "", formatQuantity(t.quantity), formatEuro(t.grossTotal), formatEuro(t.grossNonOss), formatEuro(t.vatToRemit), formatEuro(t.grossExclB2b), formatEuro(t.intraSupply), ].map(csvEscape).join(";")); return lines.join("\r\n"); } function buildRechnungenCsv(data) { const showQuelle = currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal"; const header = [ "Rechnungsdatum", "Rechnungsnummer", "Rechnungsland", "Rechnungsland Ländercode", "Zahlart", "Zahlungsreference der Bestellung", "USt. Id / VAT Id des Käufers (in Bestellung)", "Gesamtpreis brutto", "Gesamtpreis netto", "Brutto", "Brutto erm", "USt. Satz", "Ust. Satz erm", "Netto Betrag", "Netto Betrag erm", "Versandkosten brutto", "Versandkosten netto", "Auftragsdatum", "Bestellnummer", "Zahldatum", "Lieferadresse Name", "Tag", "Lieferland", ]; if (showQuelle) header.push("Quelle"); const lines = [header.map(csvEscape).join(";")]; data.invoices.forEach((i) => { const cols = [ i.datum, i.nummer, i.land, i.landCode, i.zahlart, i.zahlungsreferenz, i.vatId, formatEuro(i.gesamtBrutto), formatEuro(i.gesamtNetto), formatEuro(i.brutto), formatEuro(i.bruttoErm), formatQuantity(i.ustSatz), formatQuantity(i.ustSatzErm), formatEuro(i.netto), formatEuro(i.nettoErm), formatEuro(i.versandBrutto), formatEuro(i.versandNetto), i.auftragsdatum, i.bestellnummer, i.zahldatum, i.lieferName, i.tag, i.lieferland, ]; if (showQuelle) cols.push(i.quelle); lines.push(cols.map(csvEscape).join(";")); }); const t = data.totals; const totalCols = [ "Gesamt", String(data.invoices.length), "", "", "", "", "", formatEuro(t.gesamtBrutto), formatEuro(t.gesamtNetto), formatEuro(t.brutto), formatEuro(t.bruttoErm), "", "", formatEuro(t.netto), formatEuro(t.nettoErm), formatEuro(t.versandBrutto), formatEuro(t.versandNetto), "", "", "", "", "", "", ]; if (showQuelle) totalCols.push(""); lines.push(totalCols.map(csvEscape).join(";")); return lines.join("\r\n"); } function buildFifoCsv(data) { const header = ["Produkt", "Code", "Kategorie", "Stückkosten", "Menge", "Wert"]; const lines = [header.map(csvEscape).join(";")]; data.products.forEach((p) => { lines.push([ p.name, p.code, p.categId ?? "", formatEuro(p.unitCost), formatQuantity(p.qty), formatEuro(p.value), ].map(csvEscape).join(";")); }); lines.push([ "Gesamt", "", "", "", formatQuantity(data.totals.qty), formatEuro(data.totals.value), ].map(csvEscape).join(";")); return lines.join("\r\n"); } function buildMaterialkostenCsv(data) { const header = ["Produkt", "Kategorie", "Einlagerungsdatum", "Stückkosten", "Restmenge (Staffel)"]; const lines = [header.map(csvEscape).join(";")]; data.products.forEach((p) => { lines.push([ p.name, p.categId ?? "", p.createDate, formatEuro(p.unitCost), formatQuantity(p.qty), ].map(csvEscape).join(";")); }); lines.push([ "Gesamt", "", "", "", formatQuantity(data.totals.qty), ].map(csvEscape).join(";")); return lines.join("\r\n"); } function downloadCsv() { if (!lastData || lastData.view !== currentView) return; const period = selectedPeriod(); let csv; let filename; if (currentView === "amazon-abgangsland") { csv = buildAbgangslandCsv(lastData.data); filename = `umsatz-je-abgangsland_${period}.csv`; } else if (currentView === "amazon-vat-summary") { csv = buildVatCsv(lastData.data); filename = `vat-summary_${period}.csv`; } else if (currentView === "materialkosten") { csv = buildMaterialkostenCsv(lastData.data); filename = `materialkosten_${period}.csv`; } else if (currentView === "fifo-lager") { csv = buildFifoCsv(lastData.data); filename = `fifo-lager_${period}.csv`; } else if (currentView === "odoo-rechnungen" || currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal") { csv = buildRechnungenCsv(applyRechnungenFilters(lastData.data)); filename = `${currentView}_${period}.csv`; } else { return; } // Prepend a BOM so Excel detects UTF-8 (umlauts in "EU-Marktplätze"). const blob = new Blob(["\uFEFF" + csv + "\r\n"], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function updateDownloadState() { const supported = currentView === "amazon-abgangsland" || currentView === "amazon-vat-summary" || currentView === "odoo-rechnungen" || currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal" || currentView === "fifo-lager" || currentView === "materialkosten"; els.download.disabled = !(lastData && lastData.view === currentView && supported); } /* ---------- Navigation ---------- */ const VIEWS = { "amazon-abgangsland": { title: "Umsatz je Abgangsland", subtitle: "Amazon · Brutto (inkl. USt)", // Webhook query key and row normalizer for this dashboard's dataset. query: "Summe je Abgangsland", normalize: normalizeAbgangsland, }, "amazon-vat-summary": { title: "VAT Summary", subtitle: "Amazon · USt-Übersicht je Land", query: "VAT Summary", normalize: normalizeVat, }, "odoo-rechnungen": { title: "ODOO Rechnungen pro Monat", subtitle: "ODOO · Rechnungen je Monat", query: "ODOO Rechnungen pro Monat", normalize: normalizeRechnungen, }, "fifo-lager": { title: "FIFO Lager Bewertung", subtitle: "ODOO · Lagerwert (FIFO)", query: "FIFO Lager Bewertung", normalize: normalizeFifo, }, "materialkosten": { title: "Produkt Materialkosten", subtitle: "ODOO · Materialkosten je Produkt (Kostenstaffel)", query: "Produkt Materialkosten", normalize: normalizeMaterialkosten, }, "gesamt-rechnungen-pivot": { title: "Alle Rechnungen pro Monat", subtitle: "Gesamt · Pivot", query: "Alle Rechnungen pro Monat", normalize: normalizeRechnungen, }, "schweiz-quartal": { title: "Quartal Schweiz", subtitle: "Schweiz · AMAZON + ODOO", query: "Quartal Schweiz", normalize: normalizeRechnungen, quarter: true, }, }; function setupMenu() { // Main menu items toggle their submenu (all collapsed on load). document.querySelectorAll("[data-toggle]").forEach((button) => { button.addEventListener("click", () => { const group = button.closest("[data-group]"); const open = group.classList.toggle("open"); button.setAttribute("aria-expanded", String(open)); }); }); // Submenu items switch the visible view. document.querySelectorAll(".submenu-item").forEach((item) => { item.addEventListener("click", (event) => { event.preventDefault(); showView(item.dataset.view); }); }); } function showView(name) { const meta = VIEWS[name]; if (!meta) return; document.querySelectorAll(".view").forEach((view) => view.classList.add("hidden")); document.getElementById(`view-${name}`).classList.remove("hidden"); document.querySelectorAll(".submenu-item").forEach((item) => { item.classList.toggle("is-active", item.dataset.view === name); }); document.getElementById("view-title").textContent = meta.title; document.getElementById("view-subtitle").textContent = meta.subtitle; currentView = name; // Quarter views use the quarter selector instead of the month selector. const isQuarter = !!meta.quarter; els.month.classList.toggle("hidden", isQuarter); els.quarter.classList.toggle("hidden", !isQuarter); // Query only this dashboard's dataset — nothing is prefetched. load(); updateDownloadState(); } /* ---------- Init ---------- */ function setupAuth() { els.authForm.addEventListener("submit", (event) => { event.preventDefault(); const user = els.authUser.value.trim(); const pass = els.authPass.value; if (!user) return; auth = { user, pass }; setStoredAuth(user, pass); hideAuth(); load(); }); els.authCancel.addEventListener("click", () => { hideAuth(); showError("Anmeldung abgebrochen. Bitte erneut versuchen."); }); } buildPeriodSelectors(); setupMenu(); setupAuth(); setupRechnungenFilters(); setupRechnungenPivot(); load(); els.month.addEventListener("change", load); els.quarter.addEventListener("change", load); els.year.addEventListener("change", load); els.refresh.addEventListener("click", load); els.download.addEventListener("click", downloadCsv); load();