This commit is contained in:
oliver committed 2026-10-09 15:01:40 -03:00
commit 1ea3de74f2
10 files changed
+1825

No files matched your search

+699
View File
@@ -0,0 +1,699 @@
"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 = `
<div class="kpi primary">
<div class="kpi-label">Gesamtumsatz</div>
<div class="kpi-value">${formatEuro(total)}</div>
<div class="kpi-sub">Brutto inkl. USt</div>
</div>
<div class="kpi">
<div class="kpi-label">Abgangsländer</div>
<div class="kpi-value">${countries.length}</div>
<div class="kpi-sub">mit Umsatz im Zeitraum</div>
</div>
<div class="kpi">
<div class="kpi-label">Stärkstes Land</div>
<div class="kpi-value">${top ? top.land : "–"}</div>
<div class="kpi-sub">${top ? formatEuro(top.brutto) : "keine Daten"}</div>
</div>
<div class="kpi">
<div class="kpi-label">Ø je Land</div>
<div class="kpi-value">${formatEuro(avg)}</div>
<div class="kpi-sub">Durchschnitt</div>
</div>`;
}
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 = '<p class="muted">Keine Daten für diesen Zeitraum.</p>';
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 = `
<div class="bar-country">${c.land}</div>
<div class="bar-track">
<div class="bar-fill" style="background:${color}"></div>
</div>
<div class="bar-value">${formatEuro(c.brutto)} · ${formatPercent(share)}</div>`;
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 = `<span class="legend-dot" style="background:${color}"></span>${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 = `
<td><span class="pill">${c.bereich}</span></td>
<td>${c.land}</td>
<td class="num">${formatEuro(c.brutto)}</td>
<td class="num">
<span class="share">
<span class="share-bar"><i style="width:${share}%"></i></span>
${formatPercent(share)}
</span>
</td>`;
els.table.appendChild(tr);
});
const tr = document.createElement("tr");
tr.className = "total";
tr.innerHTML = `
<td>Gesamt</td>
<td></td>
<td class="num">${formatEuro(total)}</td>
<td class="num">100,0 %</td>`;
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 = `
<div class="kpi primary">
<div class="kpi-label">Gross Sales (Total)</div>
<div class="kpi-value">${formatEuro(totals.grossTotal)}</div>
<div class="kpi-sub">Summe aller Länder</div>
</div>
<div class="kpi">
<div class="kpi-label">VAT to Remit (Non-OSS)</div>
<div class="kpi-value">${formatEuro(totals.vatToRemit)}</div>
<div class="kpi-sub">abzuführende USt</div>
</div>
<div class="kpi">
<div class="kpi-label">Länder</div>
<div class="kpi-value">${countries.length}</div>
<div class="kpi-sub">mit Umsatz im Zeitraum</div>
</div>
<div class="kpi">
<div class="kpi-label">Menge</div>
<div class="kpi-value">${formatQuantity(totals.quantity)}</div>
<div class="kpi-sub">Artikel gesamt</div>
</div>`;
}
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 = '<p class="muted">Keine Daten für diesen Zeitraum.</p>';
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 = `
<div class="bar-country">${c.code}</div>
<div class="bar-track">
<div class="bar-fill" style="background:var(--accent)"></div>
</div>
<div class="bar-value">${formatEuro(c.grossTotal)} · ${formatPercent(share)}</div>`;
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 = `
<td><span class="pill">${c.code}</span> ${c.land}</td>
<td class="num">${formatQuantity(c.quantity)}</td>
<td class="num">${formatEuro(c.grossTotal)}</td>
<td class="num">${formatEuro(c.grossNonOss)}</td>
<td class="num">${formatEuro(c.vatToRemit)}</td>
<td class="num">${formatEuro(c.grossExclB2b)}</td>
<td class="num">${formatEuro(c.intraSupply)}</td>`;
els.vatTable.appendChild(tr);
});
const tr = document.createElement("tr");
tr.className = "total";
tr.innerHTML = `
<td>Gesamt</td>
<td class="num">${formatQuantity(totals.quantity)}</td>
<td class="num">${formatEuro(totals.grossTotal)}</td>
<td class="num">${formatEuro(totals.grossNonOss)}</td>
<td class="num">${formatEuro(totals.vatToRemit)}</td>
<td class="num">${formatEuro(totals.grossExclB2b)}</td>
<td class="num">${formatEuro(totals.intraSupply)}</td>`;
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(() => '<div class="kpi"><div class="skeleton" style="height:60px"></div></div>')
.join("");
}
function skeletonBars() {
return Array.from({ length: 5 }).map(() => '<div class="skeleton"></div>').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();