"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"), 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"), // 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. let currentView = "amazon-abgangsland"; let lastData = null; // { abgangsland, vat } /* ---------- 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.year.value = String(defYear); } function selectedPeriod() { const month = MONTHS[Number(els.month.value)]; const year = els.year.value; return `${year}-${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) { 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 }; } /* ---------- 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); } /* ---------- 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 }; } return null; } function renderActiveView() { if (!lastData) return; if (currentView === "amazon-abgangsland") { renderAbgangsland(lastData.abgangsland); } else if (currentView === "amazon-vat-summary") { renderVat(lastData.vat); } } /* ---------- 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"); v.kpis.innerHTML = skeletonKpis(); v.chart.innerHTML = skeletonBars(); v.legend.innerHTML = ""; v.table.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; v.kpis.innerHTML = ""; v.chart.innerHTML = ""; v.legend.innerHTML = ""; v.table.innerHTML = ""; } async function load() { const period = selectedPeriod(); els.tablePeriod.textContent = period; els.vatPeriod.textContent = period; els.refresh.classList.add("spinning"); showSkeleton(); try { const headers = { Accept: "application/json" }; const authorization = authHeader(); if (authorization) headers.Authorization = authorization; const response = await fetch(`${API_URL}?month=${encodeURIComponent(period)}`, { headers }); // 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 = { abgangsland: normalizeAbgangsland(extractArray(payload, "Summe je Abgangsland")), vat: normalizeVat(extractArray(payload, "VAT Summary")), }; renderActiveView(); const v = viewEls(); if (v) { v.state.className = "state hidden"; v.state.textContent = ""; } } catch (error) { 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 downloadCsv() { if (!lastData) return; const period = selectedPeriod(); let csv; let filename; if (currentView === "amazon-abgangsland") { csv = buildAbgangslandCsv(lastData.abgangsland); filename = `umsatz-je-abgangsland_${period}.csv`; } else if (currentView === "amazon-vat-summary") { csv = buildVatCsv(lastData.vat); filename = `vat-summary_${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"; els.download.disabled = !(lastData && supported); } /* ---------- Navigation ---------- */ const VIEWS = { "amazon-abgangsland": { title: "Umsatz je Abgangsland", subtitle: "Amazon · Brutto (inkl. USt)", }, "amazon-vat-summary": { title: "VAT Summary", subtitle: "Amazon · USt-Übersicht je Land", }, "odoo-dashboard": { title: "ODOO Dashboard", subtitle: "ODOO", }, }; 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; renderActiveView(); 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(); els.month.addEventListener("change", load); els.year.addEventListener("change", load); els.refresh.addEventListener("click", load); els.download.addEventListener("click", downloadCsv); load();