Files
brandize_bi/app.js
T
2026-10-27 12:30:28 -03:00

1422 lines
47 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"),
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 = `
<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);
}
/* ---------- 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 = `
<div class="kpi primary">
<div class="kpi-label">Gesamt brutto</div>
<div class="kpi-value">${formatEuro(totals.gesamtBrutto)}</div>
<div class="kpi-sub">alle Rechnungen im Zeitraum</div>
</div>
<div class="kpi">
<div class="kpi-label">Rechnungen</div>
<div class="kpi-value">${invoices.length}</div>
<div class="kpi-sub">Datensätze</div>
</div>
<div class="kpi">
<div class="kpi-label">Gesamt netto</div>
<div class="kpi-value">${formatEuro(totals.gesamtNetto)}</div>
<div class="kpi-sub">ohne USt</div>
</div>
<div class="kpi">
<div class="kpi-label">USt (brutto − netto)</div>
<div class="kpi-value">${formatEuro(ust)}</div>
<div class="kpi-sub">enthaltene Umsatzsteuer</div>
</div>`;
}
function renderFifo(data) {
const v = viewEls();
if (!v) return;
const { products, totals, productCount } = data;
v.kpis.innerHTML = `
<div class="kpi primary">
<div class="kpi-label">Lagerwert (FIFO)</div>
<div class="kpi-value">${formatEuro(totals.value)}</div>
<div class="kpi-sub">Summe Stückkosten × Restmenge</div>
</div>
<div class="kpi">
<div class="kpi-label">Produkte</div>
<div class="kpi-value">${productCount}</div>
<div class="kpi-sub">verschiedene Artikel</div>
</div>
<div class="kpi">
<div class="kpi-label">Menge</div>
<div class="kpi-value">${formatQuantity(totals.qty)}</div>
<div class="kpi-sub">Restmenge gesamt</div>
</div>
<div class="kpi">
<div class="kpi-label">Positionen</div>
<div class="kpi-value">${products.length}</div>
<div class="kpi-sub">FIFO-Schichten</div>
</div>`;
if (els.fifoInfo) els.fifoInfo.textContent = `${products.length} Positionen`;
v.table.innerHTML = "";
products.forEach((p) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${p.name}</td>
<td>${p.code}</td>
<td class="num">${p.categId ?? ""}</td>
<td class="num">${formatEuro(p.unitCost)}</td>
<td class="num">${formatQuantity(p.qty)}</td>
<td class="num">${formatEuro(p.value)}</td>`;
v.table.appendChild(tr);
});
const tr = document.createElement("tr");
tr.className = "total";
tr.innerHTML = `
<td>Gesamt</td>
<td></td>
<td class="num"></td>
<td class="num"></td>
<td class="num">${formatQuantity(totals.qty)}</td>
<td class="num">${formatEuro(totals.value)}</td>`;
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 = `
<td>${p.name}</td>
<td class="num">${p.categId ?? ""}</td>
<td>${p.createDate}</td>
<td class="num">${formatEuro(p.unitCost)}</td>
<td class="num">${formatQuantity(p.qty)}</td>`;
v.table.appendChild(tr);
});
const tr = document.createElement("tr");
tr.className = "total";
tr.innerHTML = `
<td>Gesamt</td>
<td class="num"></td>
<td></td>
<td class="num"></td>
<td class="num">${formatQuantity(totals.qty)}</td>`;
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 = '<p class="muted">Keine Daten für diesen Zeitraum.</p>';
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 = `
<div class="bar-country">${code}</div>
<div class="bar-track">
<div class="bar-fill" style="background:var(--accent)"></div>
</div>
<div class="bar-value">${formatEuro(value)} · ${formatPercent(share)}</div>`;
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 = '<p class="muted">PivotTable.js ist nicht geladen (offline?).</p>';
return;
}
if (!data.invoices.length) {
el.innerHTML = '<p class="muted">Keine Daten für diesen Zeitraum.</p>';
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(() => '<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");
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,
},
"amazon-cross-border": {
title: "Cross Boarder Inv. Movement",
subtitle: "Amazon · In Bearbeitung",
},
"amazon-buchhaltung": {
title: "Auswertung Buchhaltung",
subtitle: "Amazon · In Bearbeitung",
},
"amazon-esl-zm": {
title: "ESL/ZM-Check",
subtitle: "Amazon · In Bearbeitung",
},
"amazon-inventory-summary": {
title: "Inventory Summary",
subtitle: "Amazon · In Bearbeitung",
},
"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();