1168 lines
39 KiB
JavaScript
1168 lines
39 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"),
|
||
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"),
|
||
// 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");
|
||
}
|
||
|
||
/* ---------- 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) };
|
||
}
|
||
|
||
/* ---------- 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 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 === "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));
|
||
}
|
||
}
|
||
|
||
/* ---------- 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();
|
||
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;
|
||
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 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 === "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";
|
||
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,
|
||
},
|
||
"odoo-rechnungen": {
|
||
title: "ODOO Rechnungen pro Monat",
|
||
subtitle: "ODOO · Rechnungen je Monat",
|
||
query: "ODOO Rechnungen pro Monat",
|
||
normalize: normalizeRechnungen,
|
||
},
|
||
"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();
|