700 lines
21 KiB
JavaScript
700 lines
21 KiB
JavaScript
"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();
|