Files
brandize_bi/app.js
T
2026-10-09 15:01:40 -03:00

700 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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();