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

No files matched your search

+1
View File
@@ -0,0 +1 @@
data/
+77
View File
@@ -0,0 +1,77 @@
# BI Dashboard
A flat, server-free dashboard for the Brandize BI webhook
(`Summe je Abgangsland`). It shows the gross revenue (Brutto inkl. USt) per
departure country for a selected period, with KPI cards, a bar chart and a
detail table.
## Run
Just open `index.html` in a browser (double-click it). No server, no build step.
The page calls the webhook directly:
```
https://brandize.app.n8n.cloud/webhook/bi-dashboard
```
The webhook sends CORS headers (it reflects the request `Origin`, including
`null` for `file://`), so a direct browser call works.
## Navigation
The left sidebar groups reports by topic:
- **Amazon** → *Umsatz je Abgangsland* (Brutto inkl. USt je Abgangsland)
- **Amazon** → *VAT Summary* (USt-Übersicht je Land)
- **ODOO** → *Dashboard* (placeholder, to follow)
Both groups start collapsed, so no submenu is visible when the page opens.
Clicking a topic expands its submenu; clicking a submenu item switches the
view.
## Authentication
The webhooks are protected with HTTP Basic Auth. The dashboard asks for the
credentials when they are needed: the first request is sent without them, and
when the webhook answers `401`, a login prompt appears. The entered
credentials are sent as an `Authorization: Basic ...` header on every request
and kept in `sessionStorage` for the rest of the browser session (so a reload
does not prompt again). If the webhook rejects them (`401`), the prompt
reappears. Storage is best-effort: on `file://` it may be unavailable, in which
case credentials are kept in memory only.
## CSV export
The header has a **CSV** button that downloads the data of the currently
displayed dashboard, as a semicolon-delimited UTF-8 file (with BOM) including a
Gesamt row:
- *Umsatz je Abgangsland* → `umsatz-je-abgangsland_YYYY-MMM.csv`
- *VAT Summary* → `vat-summary_YYYY-MMM.csv`
The button is disabled on dashboards without data (e.g. the ODOO placeholder).
## Filter
A shared filter bar sits above every dashboard (labelled **Filter** with a
funnel icon and the hint *gilt für alle Dashboards*). It holds the **Zeitraum**
month + year selector (e.g. `2026-SEP`) and the reload button, and stays visible
across all views. The previous month is preselected. The selected period is
sent to the webhook as `?month=YYYY-MMM`, matching the `%(month)s` parameter
used in `../sql/Summe_je_Abgangsland.sql`.
## Data sources
One request to the webhook returns both datasets, which are read by key:
- `Summe je Abgangsland` → the *Umsatz je Abgangsland* report
- `VAT Summary` → the *VAT Summary* report
## Files
| File | Purpose |
| --- | --- |
| `index.html` | Dashboard markup |
| `styles.css` | Styling |
| `app.js` | Data loading, parsing and rendering |
+699
View File
@@ -0,0 +1,699 @@
"use strict";
const API_URL = "https://brandize.app.n8n.cloud/webhook/bi-dashboard";
const MONTHS = ["JAN", "FEB", "MÄR", "APR", "MAI", "JUN", "JUL", "AUG", "SEP", "OKT", "NOV", "DEZ"];
const PALETTE = ["#4f46e5", "#0ea5e9", "#f59e0b", "#10b981", "#ec4899", "#8b5cf6", "#ef4444", "#14b8a6"];
const els = {
month: document.getElementById("month-select"),
year: document.getElementById("year-select"),
refresh: document.getElementById("refresh-btn"),
download: document.getElementById("download-btn"),
// Amazon · Umsatz je Abgangsland
kpis: document.getElementById("kpis"),
chart: document.getElementById("chart"),
legend: document.getElementById("legend"),
table: document.querySelector("#data-table tbody"),
tablePeriod: document.getElementById("table-period"),
state: document.getElementById("state"),
// Amazon · VAT Summary
vatKpis: document.getElementById("vat-kpis"),
vatChart: document.getElementById("vat-chart"),
vatLegend: document.getElementById("vat-legend"),
vatTable: document.querySelector("#vat-table tbody"),
vatPeriod: document.getElementById("vat-period"),
vatState: document.getElementById("vat-state"),
// Auth
authOverlay: document.getElementById("auth-overlay"),
authForm: document.getElementById("auth-form"),
authUser: document.getElementById("auth-user"),
authPass: document.getElementById("auth-pass"),
authError: document.getElementById("auth-error"),
authCancel: document.getElementById("auth-cancel"),
};
// State for the currently displayed dashboard and its last loaded data.
let currentView = "amazon-abgangsland";
let lastData = null; // { abgangsland, vat }
/* ---------- Auth (HTTP Basic) ---------- */
const AUTH_KEY = "brandize-bi-auth";
function getStoredAuth() {
try {
const raw = sessionStorage.getItem(AUTH_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function setStoredAuth(user, pass) {
try {
sessionStorage.setItem(AUTH_KEY, JSON.stringify({ user, pass }));
} catch {
/* storage unavailable (e.g. file://) — keep in memory only */
}
}
function clearStoredAuth() {
try {
sessionStorage.removeItem(AUTH_KEY);
} catch {
/* ignore */
}
}
let auth = getStoredAuth();
function base64(value) {
const bytes = new TextEncoder().encode(value);
let binary = "";
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return btoa(binary);
}
function authHeader() {
return auth ? `Basic ${base64(`${auth.user}:${auth.pass}`)}` : null;
}
function showAuth(message) {
els.authError.textContent = message || "";
els.authError.classList.toggle("hidden", !message);
els.authOverlay.classList.remove("hidden");
els.authUser.focus();
}
function hideAuth() {
els.authOverlay.classList.add("hidden");
}
/* ---------- Period selector ---------- */
function lastMonth() {
const now = new Date();
let year = now.getFullYear();
let month = now.getMonth() - 1; // 0-based, previous month
if (month < 0) {
month = 11;
year -= 1;
}
return { year, month };
}
function buildPeriodSelectors() {
const currentYear = new Date().getFullYear();
const { year: defYear, month: defMonth } = lastMonth();
MONTHS.forEach((label, index) => {
const opt = document.createElement("option");
opt.value = String(index);
opt.textContent = label;
els.month.appendChild(opt);
});
for (let y = currentYear - 2; y <= currentYear; y++) {
const opt = document.createElement("option");
opt.value = String(y);
opt.textContent = String(y);
els.year.appendChild(opt);
}
els.month.value = String(defMonth);
els.year.value = String(defYear);
}
function selectedPeriod() {
const month = MONTHS[Number(els.month.value)];
const year = els.year.value;
return `${year}-${month}`;
}
/* ---------- Formatting ---------- */
function parseEuro(value) {
if (value == null) return 0;
const cleaned = String(value).replace(/[^\d.,-]/g, "").replace(/\./g, "").replace(",", ".");
const num = parseFloat(cleaned);
return Number.isFinite(num) ? num : 0;
}
function parseQuantity(value) {
if (value == null) return 0;
const digits = String(value).replace(/[^\d]/g, "");
return digits ? parseInt(digits, 10) : 0;
}
function formatEuro(value) {
return value.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
}
function formatPercent(value) {
return value.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + " %";
}
function formatQuantity(value) {
return value.toLocaleString("de-DE");
}
/* ---------- Data ---------- */
function extractArray(payload, key) {
if (!payload || typeof payload !== "object") return [];
if (Array.isArray(payload[key])) return payload[key];
const match = Object.keys(payload).find((k) => k.toLowerCase() === key.toLowerCase());
return match && Array.isArray(payload[match]) ? payload[match] : [];
}
function normalizeAbgangsland(rows) {
const countries = [];
let total = null;
for (const row of rows) {
const bereich = row["Bereich"] ?? "";
const land = row["Abgangsland"] ?? "";
const brutto = parseEuro(row["Brutto (inkl. USt)"]);
if (bereich === "Gesamt" || land === "") {
total = (total || 0) + brutto;
} else {
countries.push({ bereich, land, brutto });
}
}
countries.sort((a, b) => b.brutto - a.brutto);
const sum = total != null ? total : countries.reduce((acc, c) => acc + c.brutto, 0);
return { countries, total: sum };
}
function normalizeVat(rows) {
const countries = rows.map((row) => ({
code: row["Country Code"] ?? "",
land: row["Land"] ?? "",
quantity: parseQuantity(row["Quantity"]),
grossTotal: parseEuro(row["Gross Sales (Total)"]),
grossNonOss: parseEuro(row["Gross Sales (Non-OSS)"]),
vatToRemit: parseEuro(row["VAT to Remit (Non-OSS)"]),
grossExclB2b: parseEuro(row["Gross Sales (Excl. B2B)"]),
intraSupply: parseEuro(row["Intra-community supply – Net sales"]),
}));
countries.sort((a, b) => b.grossTotal - a.grossTotal);
const totals = countries.reduce(
(acc, c) => {
acc.quantity += c.quantity;
acc.grossTotal += c.grossTotal;
acc.grossNonOss += c.grossNonOss;
acc.vatToRemit += c.vatToRemit;
acc.grossExclB2b += c.grossExclB2b;
acc.intraSupply += c.intraSupply;
return acc;
},
{ quantity: 0, grossTotal: 0, grossNonOss: 0, vatToRemit: 0, grossExclB2b: 0, intraSupply: 0 }
);
return { countries, totals };
}
/* ---------- Rendering: Umsatz je Abgangsland ---------- */
function colorFor(bereich, map) {
if (!map.has(bereich)) map.set(bereich, PALETTE[map.size % PALETTE.length]);
return map.get(bereich);
}
function renderAbgangslandKpis(data) {
const { countries, total } = data;
const top = countries[0];
const avg = countries.length ? total / countries.length : 0;
els.kpis.innerHTML = `
<div class="kpi primary">
<div class="kpi-label">Gesamtumsatz</div>
<div class="kpi-value">${formatEuro(total)}</div>
<div class="kpi-sub">Brutto inkl. USt</div>
</div>
<div class="kpi">
<div class="kpi-label">Abgangsländer</div>
<div class="kpi-value">${countries.length}</div>
<div class="kpi-sub">mit Umsatz im Zeitraum</div>
</div>
<div class="kpi">
<div class="kpi-label">Stärkstes Land</div>
<div class="kpi-value">${top ? top.land : "–"}</div>
<div class="kpi-sub">${top ? formatEuro(top.brutto) : "keine Daten"}</div>
</div>
<div class="kpi">
<div class="kpi-label">Ø je Land</div>
<div class="kpi-value">${formatEuro(avg)}</div>
<div class="kpi-sub">Durchschnitt</div>
</div>`;
}
function renderAbgangslandChart(data) {
const { countries, total } = data;
const colorMap = new Map();
const max = countries.reduce((m, c) => Math.max(m, c.brutto), 0) || 1;
els.chart.innerHTML = "";
els.legend.innerHTML = "";
if (!countries.length) {
els.chart.innerHTML = '<p class="muted">Keine Daten für diesen Zeitraum.</p>';
return;
}
countries.forEach((c) => {
const color = colorFor(c.bereich, colorMap);
const pct = (c.brutto / max) * 100;
const share = total ? (c.brutto / total) * 100 : 0;
const row = document.createElement("div");
row.className = "bar-row";
row.innerHTML = `
<div class="bar-country">${c.land}</div>
<div class="bar-track">
<div class="bar-fill" style="background:${color}"></div>
</div>
<div class="bar-value">${formatEuro(c.brutto)} · ${formatPercent(share)}</div>`;
els.chart.appendChild(row);
const fill = row.querySelector(".bar-fill");
// Force a reflow so the width transition animates from 0 to the target.
fill.getBoundingClientRect();
fill.style.width = Math.max(pct, 6) + "%";
fill.classList.add("ready");
});
colorMap.forEach((color, bereich) => {
const item = document.createElement("div");
item.className = "legend-item";
item.innerHTML = `<span class="legend-dot" style="background:${color}"></span>${bereich}`;
els.legend.appendChild(item);
});
}
function renderAbgangslandTable(data) {
const { countries, total } = data;
els.table.innerHTML = "";
countries.forEach((c) => {
const share = total ? (c.brutto / total) * 100 : 0;
const tr = document.createElement("tr");
tr.innerHTML = `
<td><span class="pill">${c.bereich}</span></td>
<td>${c.land}</td>
<td class="num">${formatEuro(c.brutto)}</td>
<td class="num">
<span class="share">
<span class="share-bar"><i style="width:${share}%"></i></span>
${formatPercent(share)}
</span>
</td>`;
els.table.appendChild(tr);
});
const tr = document.createElement("tr");
tr.className = "total";
tr.innerHTML = `
<td>Gesamt</td>
<td></td>
<td class="num">${formatEuro(total)}</td>
<td class="num">100,0 %</td>`;
els.table.appendChild(tr);
}
function renderAbgangsland(data) {
renderAbgangslandKpis(data);
renderAbgangslandChart(data);
renderAbgangslandTable(data);
}
/* ---------- Rendering: VAT Summary ---------- */
function renderVatKpis(data) {
const { countries, totals } = data;
els.vatKpis.innerHTML = `
<div class="kpi primary">
<div class="kpi-label">Gross Sales (Total)</div>
<div class="kpi-value">${formatEuro(totals.grossTotal)}</div>
<div class="kpi-sub">Summe aller Länder</div>
</div>
<div class="kpi">
<div class="kpi-label">VAT to Remit (Non-OSS)</div>
<div class="kpi-value">${formatEuro(totals.vatToRemit)}</div>
<div class="kpi-sub">abzuführende USt</div>
</div>
<div class="kpi">
<div class="kpi-label">Länder</div>
<div class="kpi-value">${countries.length}</div>
<div class="kpi-sub">mit Umsatz im Zeitraum</div>
</div>
<div class="kpi">
<div class="kpi-label">Menge</div>
<div class="kpi-value">${formatQuantity(totals.quantity)}</div>
<div class="kpi-sub">Artikel gesamt</div>
</div>`;
}
function renderVatChart(data) {
const { countries, totals } = data;
const max = countries.reduce((m, c) => Math.max(m, c.grossTotal), 0) || 1;
els.vatChart.innerHTML = "";
els.vatLegend.innerHTML = "";
if (!countries.length) {
els.vatChart.innerHTML = '<p class="muted">Keine Daten für diesen Zeitraum.</p>';
return;
}
countries.forEach((c) => {
const pct = (c.grossTotal / max) * 100;
const share = totals.grossTotal ? (c.grossTotal / totals.grossTotal) * 100 : 0;
const row = document.createElement("div");
row.className = "bar-row";
row.innerHTML = `
<div class="bar-country">${c.code}</div>
<div class="bar-track">
<div class="bar-fill" style="background:var(--accent)"></div>
</div>
<div class="bar-value">${formatEuro(c.grossTotal)} · ${formatPercent(share)}</div>`;
els.vatChart.appendChild(row);
const fill = row.querySelector(".bar-fill");
fill.getBoundingClientRect();
fill.style.width = Math.max(pct, 6) + "%";
fill.classList.add("ready");
});
}
function renderVatTable(data) {
const { countries, totals } = data;
els.vatTable.innerHTML = "";
countries.forEach((c) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><span class="pill">${c.code}</span> ${c.land}</td>
<td class="num">${formatQuantity(c.quantity)}</td>
<td class="num">${formatEuro(c.grossTotal)}</td>
<td class="num">${formatEuro(c.grossNonOss)}</td>
<td class="num">${formatEuro(c.vatToRemit)}</td>
<td class="num">${formatEuro(c.grossExclB2b)}</td>
<td class="num">${formatEuro(c.intraSupply)}</td>`;
els.vatTable.appendChild(tr);
});
const tr = document.createElement("tr");
tr.className = "total";
tr.innerHTML = `
<td>Gesamt</td>
<td class="num">${formatQuantity(totals.quantity)}</td>
<td class="num">${formatEuro(totals.grossTotal)}</td>
<td class="num">${formatEuro(totals.grossNonOss)}</td>
<td class="num">${formatEuro(totals.vatToRemit)}</td>
<td class="num">${formatEuro(totals.grossExclB2b)}</td>
<td class="num">${formatEuro(totals.intraSupply)}</td>`;
els.vatTable.appendChild(tr);
}
function renderVat(data) {
renderVatKpis(data);
renderVatChart(data);
renderVatTable(data);
}
/* ---------- Active view helpers ---------- */
function viewEls() {
if (currentView === "amazon-abgangsland") {
return { kpis: els.kpis, chart: els.chart, legend: els.legend, table: els.table, state: els.state };
}
if (currentView === "amazon-vat-summary") {
return { kpis: els.vatKpis, chart: els.vatChart, legend: els.vatLegend, table: els.vatTable, state: els.vatState };
}
return null;
}
function renderActiveView() {
if (!lastData) return;
if (currentView === "amazon-abgangsland") {
renderAbgangsland(lastData.abgangsland);
} else if (currentView === "amazon-vat-summary") {
renderVat(lastData.vat);
}
}
/* ---------- Loading ---------- */
function skeletonKpis() {
return Array.from({ length: 4 })
.map(() => '<div class="kpi"><div class="skeleton" style="height:60px"></div></div>')
.join("");
}
function skeletonBars() {
return Array.from({ length: 5 }).map(() => '<div class="skeleton"></div>').join("");
}
function showSkeleton() {
const v = viewEls();
if (!v) return;
v.state.classList.add("hidden");
v.kpis.innerHTML = skeletonKpis();
v.chart.innerHTML = skeletonBars();
v.legend.innerHTML = "";
v.table.innerHTML = "";
}
function showError(message) {
const v = viewEls();
if (!v) return;
v.state.className = "state error";
v.state.textContent = message;
}
function clearViews() {
const v = viewEls();
if (!v) return;
v.kpis.innerHTML = "";
v.chart.innerHTML = "";
v.legend.innerHTML = "";
v.table.innerHTML = "";
}
async function load() {
const period = selectedPeriod();
els.tablePeriod.textContent = period;
els.vatPeriod.textContent = period;
els.refresh.classList.add("spinning");
showSkeleton();
try {
const headers = { Accept: "application/json" };
const authorization = authHeader();
if (authorization) headers.Authorization = authorization;
const response = await fetch(`${API_URL}?month=${encodeURIComponent(period)}`, { headers });
// The webhook is protected with Basic Auth: ask for credentials when needed.
if (response.status === 401) {
lastData = null;
clearStoredAuth();
auth = null;
clearViews();
const v = viewEls();
if (v) v.state.className = "state hidden";
showAuth("Bitte Zugangsdaten eingeben.");
return;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
lastData = {
abgangsland: normalizeAbgangsland(extractArray(payload, "Summe je Abgangsland")),
vat: normalizeVat(extractArray(payload, "VAT Summary")),
};
renderActiveView();
const v = viewEls();
if (v) {
v.state.className = "state hidden";
v.state.textContent = "";
}
} catch (error) {
lastData = null;
clearViews();
showError(`Daten konnten nicht geladen werden: ${error.message}`);
} finally {
els.refresh.classList.remove("spinning");
updateDownloadState();
}
}
/* ---------- CSV export ---------- */
function csvEscape(value) {
return `"${String(value ?? "").replace(/"/g, '""')}"`;
}
function buildAbgangslandCsv(data) {
const lines = [["Bereich", "Abgangsland", "Brutto (inkl. USt)", "Anteil"].map(csvEscape).join(";")];
data.countries.forEach((c) => {
const share = data.total ? (c.brutto / data.total) * 100 : 0;
lines.push([c.bereich, c.land, formatEuro(c.brutto), formatPercent(share)].map(csvEscape).join(";"));
});
lines.push(["Gesamt", "", formatEuro(data.total), "100,0 %"].map(csvEscape).join(";"));
return lines.join("\r\n");
}
function buildVatCsv(data) {
const header = [
"Country Code", "Land", "Quantity", "Gross Sales (Total)", "Gross Sales (Non-OSS)",
"VAT to Remit (Non-OSS)", "Gross Sales (Excl. B2B)", "Intra-community supply – Net sales",
];
const lines = [header.map(csvEscape).join(";")];
data.countries.forEach((c) => {
lines.push([
c.code, c.land, formatQuantity(c.quantity), formatEuro(c.grossTotal), formatEuro(c.grossNonOss),
formatEuro(c.vatToRemit), formatEuro(c.grossExclB2b), formatEuro(c.intraSupply),
].map(csvEscape).join(";"));
});
const t = data.totals;
lines.push([
"Gesamt", "", formatQuantity(t.quantity), formatEuro(t.grossTotal), formatEuro(t.grossNonOss),
formatEuro(t.vatToRemit), formatEuro(t.grossExclB2b), formatEuro(t.intraSupply),
].map(csvEscape).join(";"));
return lines.join("\r\n");
}
function downloadCsv() {
if (!lastData) return;
const period = selectedPeriod();
let csv;
let filename;
if (currentView === "amazon-abgangsland") {
csv = buildAbgangslandCsv(lastData.abgangsland);
filename = `umsatz-je-abgangsland_${period}.csv`;
} else if (currentView === "amazon-vat-summary") {
csv = buildVatCsv(lastData.vat);
filename = `vat-summary_${period}.csv`;
} else {
return;
}
// Prepend a BOM so Excel detects UTF-8 (umlauts in "EU-Marktplätze").
const blob = new Blob(["\uFEFF" + csv + "\r\n"], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function updateDownloadState() {
const supported = currentView === "amazon-abgangsland" || currentView === "amazon-vat-summary";
els.download.disabled = !(lastData && supported);
}
/* ---------- Navigation ---------- */
const VIEWS = {
"amazon-abgangsland": {
title: "Umsatz je Abgangsland",
subtitle: "Amazon · Brutto (inkl. USt)",
},
"amazon-vat-summary": {
title: "VAT Summary",
subtitle: "Amazon · USt-Übersicht je Land",
},
"odoo-dashboard": {
title: "ODOO Dashboard",
subtitle: "ODOO",
},
};
function setupMenu() {
// Main menu items toggle their submenu (all collapsed on load).
document.querySelectorAll("[data-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const group = button.closest("[data-group]");
const open = group.classList.toggle("open");
button.setAttribute("aria-expanded", String(open));
});
});
// Submenu items switch the visible view.
document.querySelectorAll(".submenu-item").forEach((item) => {
item.addEventListener("click", (event) => {
event.preventDefault();
showView(item.dataset.view);
});
});
}
function showView(name) {
const meta = VIEWS[name];
if (!meta) return;
document.querySelectorAll(".view").forEach((view) => view.classList.add("hidden"));
document.getElementById(`view-${name}`).classList.remove("hidden");
document.querySelectorAll(".submenu-item").forEach((item) => {
item.classList.toggle("is-active", item.dataset.view === name);
});
document.getElementById("view-title").textContent = meta.title;
document.getElementById("view-subtitle").textContent = meta.subtitle;
currentView = name;
renderActiveView();
updateDownloadState();
}
/* ---------- Init ---------- */
function setupAuth() {
els.authForm.addEventListener("submit", (event) => {
event.preventDefault();
const user = els.authUser.value.trim();
const pass = els.authPass.value;
if (!user) return;
auth = { user, pass };
setStoredAuth(user, pass);
hideAuth();
load();
});
els.authCancel.addEventListener("click", () => {
hideAuth();
showError("Anmeldung abgebrochen. Bitte erneut versuchen.");
});
}
buildPeriodSelectors();
setupMenu();
setupAuth();
els.month.addEventListener("change", load);
els.year.addEventListener("change", load);
els.refresh.addEventListener("click", load);
els.download.addEventListener("click", downloadCsv);
load();
+246
View File
@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Brandize · BI Dashboard</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="layout">
<!-- ================= Sidebar ================= -->
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-mark">B</div>
<div class="brand-text">
<strong>Brandize</strong>
<span>BI Dashboard</span>
</div>
</div>
<nav class="menu">
<!-- Amazon -->
<div class="menu-group" data-group>
<button class="menu-main" type="button" data-toggle aria-expanded="false">
<span class="menu-logo">
<svg viewBox="0 0 48 48" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="#232f3e" />
<text x="24" y="25" text-anchor="middle" font-size="17" font-weight="700"
fill="#ffffff" font-family="Arial, sans-serif">a</text>
<path d="M13 30c5.5 3.6 16.5 3.6 22 0" fill="none" stroke="#ff9900"
stroke-width="3" stroke-linecap="round" />
<path d="M32.5 28.2l4.2 1.6-3.1 3.1z" fill="#ff9900" />
</svg>
</span>
<span class="menu-name">Amazon</span>
<svg class="chevron" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="submenu">
<a class="submenu-item is-active" href="#" data-view="amazon-abgangsland">
Umsatz je Abgangsland
</a>
<a class="submenu-item" href="#" data-view="amazon-vat-summary">
VAT Summary
</a>
</div>
</div>
<!-- ODOO -->
<div class="menu-group" data-group>
<button class="menu-main" type="button" data-toggle aria-expanded="false">
<span class="menu-logo">
<svg viewBox="0 0 48 48" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="#714B67" />
<text x="24" y="30" text-anchor="middle" font-size="14" font-weight="700"
fill="#ffffff" font-family="Arial, sans-serif">odoo</text>
</svg>
</span>
<span class="menu-name">ODOO</span>
<svg class="chevron" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="submenu">
<a class="submenu-item" href="#" data-view="odoo-dashboard">
Dashboard
</a>
</div>
</div>
</nav>
</aside>
<!-- ================= Main ================= -->
<div class="main">
<header class="topbar">
<div class="page-title">
<h1 id="view-title">Umsatz je Abgangsland</h1>
<p id="view-subtitle">Amazon · Brutto (inkl. USt)</p>
</div>
<button id="download-btn" class="download" type="button"
title="Daten als CSV herunterladen">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M12 3v10m0 0l-4-4m4 4l4-4M5 17v2a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-2"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>CSV</span>
</button>
</header>
<!-- Shared filter: applies to every dashboard -->
<div class="filterbar" id="filterbar">
<div class="filter-head">
<svg class="filter-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 5h18l-7 8v5l-4 2v-7L3 5Z" fill="none" stroke="currentColor"
stroke-width="2" stroke-linejoin="round" />
</svg>
<span class="filter-title">Filter</span>
<span class="filter-hint">gilt für alle Dashboards</span>
</div>
<div class="filter-fields">
<label class="field">
<span class="field-label">Zeitraum</span>
<div class="period">
<select id="month-select" aria-label="Monat"></select>
<select id="year-select" aria-label="Jahr"></select>
</div>
</label>
<button id="refresh-btn" class="refresh" type="button" title="Neu laden">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M17.65 6.35A7.95 7.95 0 0 0 12 4a8 8 0 1 0 7.73 10h-2.08A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35Z"/>
</svg>
</button>
</div>
</div>
<!-- View: Amazon · Umsatz je Abgangsland -->
<main id="view-amazon-abgangsland" class="view">
<section class="kpis" id="kpis"></section>
<section class="panel">
<div class="panel-head">
<h2>Umsatz je Abgangsland</h2>
<div class="legend" id="legend"></div>
</div>
<div class="chart" id="chart"></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Details</h2>
<span class="muted" id="table-period"></span>
</div>
<div class="table-wrap">
<table id="data-table">
<thead>
<tr>
<th>Bereich</th>
<th>Abgangsland</th>
<th class="num">Brutto (inkl. USt)</th>
<th class="num">Anteil</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<div class="state" id="state"></div>
</main>
<!-- View: Amazon · VAT Summary -->
<main id="view-amazon-vat-summary" class="view hidden">
<section class="kpis" id="vat-kpis"></section>
<section class="panel">
<div class="panel-head">
<h2>Gross Sales (Total) je Land</h2>
<div class="legend" id="vat-legend"></div>
</div>
<div class="chart" id="vat-chart"></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Details</h2>
<span class="muted" id="vat-period"></span>
</div>
<div class="table-wrap">
<table id="vat-table">
<thead>
<tr>
<th>Land</th>
<th class="num">Menge</th>
<th class="num">Gross Sales (Total)</th>
<th class="num">Gross Sales (Non-OSS)</th>
<th class="num">VAT to Remit (Non-OSS)</th>
<th class="num">Gross Sales (Excl. B2B)</th>
<th class="num">Intra-community supply – Net sales</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<div class="state" id="vat-state"></div>
</main>
<!-- View: ODOO · Dashboard (placeholder) -->
<main id="view-odoo-dashboard" class="view hidden">
<section class="panel placeholder">
<div class="placeholder-icon">
<svg viewBox="0 0 48 48" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="#714B67" />
<text x="24" y="30" text-anchor="middle" font-size="14" font-weight="700"
fill="#ffffff" font-family="Arial, sans-serif">odoo</text>
</svg>
</div>
<h2>ODOO Dashboard</h2>
<p class="muted">Dieses Dashboard folgt in Kürze.</p>
</section>
</main>
</div>
</div>
<!-- ================= Auth prompt ================= -->
<div class="auth-overlay hidden" id="auth-overlay">
<form class="auth-card" id="auth-form">
<div class="auth-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="10" width="16" height="10" rx="2" fill="none"
stroke="currentColor" stroke-width="2" />
<path d="M8 10V7a4 4 0 0 1 8 0v3" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" />
</svg>
</div>
<h2>Anmeldung erforderlich</h2>
<p class="muted">Der Webhook ist mit Basic Auth geschützt. Bitte Zugangsdaten eingeben.</p>
<label class="auth-field">
<span>Benutzername</span>
<input id="auth-user" type="text" autocomplete="username" required />
</label>
<label class="auth-field">
<span>Passwort</span>
<input id="auth-pass" type="password" autocomplete="current-password" required />
</label>
<p class="auth-error hidden" id="auth-error"></p>
<div class="auth-actions">
<button type="button" class="btn-ghost" id="auth-cancel">Abbrechen</button>
<button type="submit" class="btn-primary">Anmelden</button>
</div>
</form>
</div>
<script src="app.js"></script>
</body>
</html>
Executable
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
import csv
import sys
import time
import logging
import argparse
import requests
WEBHOOK_URL = "https://brandize.app.n8n.cloud/webhook/VAT"
BATCH_SIZE = 50
REQUEST_TIMEOUT = 60
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("send")
def load_rows(csv_file):
with open(csv_file, "r", newline="", encoding="utf-8-sig") as file:
return list(csv.DictReader(file))
def send_batch(batch):
response = requests.post(
WEBHOOK_URL,
json=batch,
headers={"Content-Type": "application/json"},
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
return response
def send_csv(csv_file, batch_size):
try:
rows = load_rows(csv_file)
except FileNotFoundError:
log.error("CSV file not found: %s", csv_file)
return 1
except OSError as e:
log.error("Could not read %s: %s", csv_file, e)
return 1
total = len(rows)
if total == 0:
log.warning("No data rows found in %s", csv_file)
return 0
num_batches = (total + batch_size - 1) // batch_size
log.info("Loaded %d rows from %s", total, csv_file)
log.info("Sending in %d batch(es) of up to %d rows", num_batches, batch_size)
sent = 0
failed = 0
start_time = time.time()
for index in range(num_batches):
batch = rows[index * batch_size:(index + 1) * batch_size]
first_row = index * batch_size + 1
last_row = first_row + len(batch) - 1
log.info(
"Batch %d/%d (rows %d-%d, %d rows) ...",
index + 1, num_batches, first_row, last_row, len(batch),
)
try:
response = send_batch(batch)
sent += len(batch)
log.info(
"Batch %d/%d OK (HTTP %d) | Progress: %d/%d rows (%.2f%%)",
index + 1, num_batches, response.status_code,
sent, total, sent / total * 100,
)
except requests.RequestException as e:
failed += 1
log.error(
"Batch %d/%d FAILED: %s | Progress: %d/%d rows (%.2f%%)",
index + 1, num_batches, e,
sent, total, sent / total * 100,
)
elapsed = time.time() - start_time
log.info(
"Done in %.1fs. %d/%d rows sent, %d batch(es) failed. Progress: %.2f%%",
elapsed, sent, total, failed, sent / total * 100,
)
return 1 if failed else 0
def main():
parser = argparse.ArgumentParser(
description="Send all rows of a CSV file to an n8n webhook in batches."
)
parser.add_argument("csv_file", help="Path to the CSV file")
parser.add_argument(
"--batch-size",
type=int,
default=BATCH_SIZE,
help="Number of rows per request (default: %(default)s)",
)
args = parser.parse_args()
if args.batch_size < 1:
parser.error("--batch-size must be at least 1.")
sys.exit(send_csv(args.csv_file, args.batch_size))
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
-- ============================================================================
-- Loeschen aller Datensaetze eines Abrechnungszeitraums
-- Odoo 17 / PostgreSQL
--
-- Modell : brandize.datacenter.amazon.vat.transaction
-- Tabelle: brandize_datacenter_amazon_vat_transaction
--
-- Parameter:
-- %(month)s Abrechnungszeitraum als Text, z. B. '2026-SEP'
--
-- Aufruf (Odoo / psycopg2):
-- cr.execute(open('Delete_Activity_Period.sql').read(), {'month': '2026-SEP'})
--
-- In psql stattdessen:
-- \set month '2026-SEP'
-- ... WHERE activity_period = :'month'
--
-- ACHTUNG: Loescht unwiderruflich. Vorher die Anzahl pruefen (siehe unten).
-- ============================================================================
-- Kontrolle vor dem Loeschen (betroffene Datensaetze zaehlen):
-- SELECT count(*) FROM brandize_datacenter_amazon_vat_transaction
-- WHERE activity_period = %(month)s;
DELETE FROM brandize_datacenter_amazon_vat_transaction
WHERE activity_period = %(month)s;
-- ----------------------------------------------------------------------------
-- In psql empfiehlt sich eine Transaktionsklammer, damit die Loeschung erst
-- nach Sichtkontrolle bestaetigt wird:
--
-- BEGIN;
-- DELETE FROM brandize_datacenter_amazon_vat_transaction
-- WHERE activity_period = :'month';
-- -- Ergebnis pruefen, dann:
-- COMMIT; -- bestaetigen
-- -- ROLLBACK; -- verwerfen
--
-- In Odoo NICHT verwenden: dort umschliesst Odoo die Transaktion selbst.
-- ----------------------------------------------------------------------------
+55
View File
@@ -0,0 +1,55 @@
-- ============================================================================
-- Summe je Abgangsland
-- Odoo 17 / PostgreSQL
--
-- Modell : brandize.datacenter.amazon.vat.transaction
-- Tabelle: brandize_datacenter_amazon_vat_transaction
--
-- Summiert den Bruttoumsatz (inkl. USt) je Bereich und Abgangsland
-- (SALE_DEPART_COUNTRY) und haengt eine Gesamt-Summe an.
--
-- Parameter:
-- %(month)s Abrechnungszeitraum als Text, z. B. '2026-SEP'
--
-- Aufruf (Odoo / psycopg2):
-- cr.execute(open('Summe_je_Abgangsland.sql').read(), {'month': '2026-SEP'})
--
-- In psql stattdessen:
-- \set month '2026-SEP'
-- ... AND activity_period = :'month'
--
-- Hinweis: Die Spaltennamen entsprechen den Odoo-Feldnamen (Kleinschreibung
-- der CSV-Header). Falls ein Feld in der DB anders heisst, hier anpassen.
-- ============================================================================
WITH base AS (
SELECT
-- Bereich: EU-Marktplaetze werden zusammengefasst, alle uebrigen
-- Marktplaetze (z. B. amazon.co.uk) erscheinen unter ihrem eigenen Namen.
CASE
WHEN marketplace IN (
'amazon.de', 'amazon.fr', 'amazon.it', 'amazon.es',
'amazon.nl', 'amazon.pl', 'amazon.se', 'amazon.cz',
'amazon.com.be', 'amazon.be'
) THEN 'EU-Marktplätze'
ELSE marketplace
END AS bereich,
sale_depart_country AS abgangsland,
total_activity_value_amt_vat_incl AS brutto
FROM brandize_datacenter_amazon_vat_transaction
WHERE sale_depart_country IS NOT NULL
AND sale_depart_country <> ''
AND activity_period = %(month)s
)
SELECT
CASE WHEN GROUPING(bereich) = 1 THEN 'Gesamt' ELSE bereich END AS "Bereich",
CASE WHEN GROUPING(bereich) = 1 THEN '' ELSE abgangsland END AS "Abgangsland",
-- Deutsche Zahlformatierung: 90717.93 -> "90.717,93 €"
translate(to_char(SUM(brutto), 'FM999,999,999,999.99'), ',.', '.,') || ' €'
AS "Brutto (inkl. USt)"
FROM base
GROUP BY GROUPING SETS ((bereich, abgangsland), ())
ORDER BY
GROUPING(bereich), -- Gesamt-Zeile zuletzt
CASE bereich WHEN 'EU-Marktplätze' THEN 0 ELSE 1 END, -- EU zuerst
abgangsland;
+105
View File
@@ -0,0 +1,105 @@
-- ============================================================================
-- VAT Summary (Kontrolle Umsatzsteuer-Voranmeldung)
-- Odoo 17 / PostgreSQL
--
-- Modell : brandize.datacenter.amazon.vat.transaction
-- Tabelle: brandize_datacenter_amazon_vat_transaction
--
-- Rechnungsdatum von / bis : 01.09.2026 / 30.09.2026
--
-- Parameter:
-- %(month)s Abrechnungszeitraum als Text, z. B. '2026-SEP'
--
-- Aufruf (Odoo / psycopg2):
-- cr.execute(open('VAT_Summary.sql').read(), {'month': '2026-SEP'})
--
-- In psql:
-- \set month '2026-SEP'
-- ... WHERE activity_period = :'month'
--
-- Excel-Namen -> SQL-Spalten:
-- QUANTITY -> qty
-- GROSS_INVOICE_AMOUNT -> total_activity_value_amt_vat_incl
-- TOTAL_VAT -> total_activity_value_vat_amt
-- IN_PERIOD -> activity_period = %(month)s
-- SALES_CHANNEL -> sales_channel (<> 'AMAZON_FEE')
-- SALE_ARRIVAL_COUNTRY -> sale_arrival_country
-- SALE_DEPART_COUNTRY -> sale_depart_country
-- BUYER_VAT_NR_COUNTRY -> buyer_vat_number_country
-- ZERO_HAS_B2B_VAT_NR -> buyer_vat_number <> ''
--
-- Hinweis: Die "bereinigten" Hilfsspalten (CU-CX) sind nur ein Excel-Workaround
-- fuer als Text/Datum importierte Zellen. In der DB sind die numerischen
-- Spalten bereits korrekt typisiert, daher werden hier die Rohspalten genutzt.
-- ============================================================================
WITH base AS (
SELECT
sale_arrival_country,
sale_depart_country,
buyer_vat_number_country,
buyer_vat_number,
qty,
total_activity_value_amt_vat_incl AS gross,
total_activity_value_vat_amt AS vat
FROM brandize_datacenter_amazon_vat_transaction
WHERE activity_period = %(month)s
AND sales_channel <> 'AMAZON_FEE'
),
arrival AS (
SELECT
sale_arrival_country AS country_code,
SUM(qty) AS quantity,
SUM(gross) AS gross_total,
-- Non-OSS: Abgangs- und Ankunftsland identisch (Inlandslieferung)
SUM(gross) FILTER (WHERE sale_depart_country = sale_arrival_country) AS gross_non_oss,
SUM(vat) FILTER (WHERE sale_depart_country = sale_arrival_country) AS vat_non_oss,
-- Excl. B2B: nur Umsaetze mit USt (TOTAL_VAT <> 0)
SUM(gross) FILTER (WHERE vat <> 0) AS gross_excl_b2b
FROM base
WHERE sale_arrival_country IS NOT NULL
AND sale_arrival_country <> ''
GROUP BY sale_arrival_country
),
intra AS (
-- Innergemeinschaftliche Lieferung: B2B mit USt-Nr. und 0 %% USt
SELECT
buyer_vat_number_country AS country_code,
SUM(gross) AS intra_net
FROM base
WHERE buyer_vat_number_country IS NOT NULL
AND buyer_vat_number_country <> ''
AND buyer_vat_number IS NOT NULL
AND buyer_vat_number <> ''
AND vat = 0
GROUP BY buyer_vat_number_country
)
SELECT
a.country_code AS "Country Code",
COALESCE(c.name->>'de_DE', c.name->>'en_US', a.country_code) AS "Land",
to_char(a.quantity, 'FM999,999,999') AS "Quantity",
translate(to_char(a.gross_total, 'FM999,999,999,999.99'), ',.', '.,') || ' €' AS "Gross Sales (Total)",
translate(to_char(a.gross_non_oss, 'FM999,999,999,999.99'), ',.', '.,') || ' €' AS "Gross Sales (Non-OSS)",
translate(to_char(a.vat_non_oss, 'FM999,999,999,999.99'), ',.', '.,') || ' €' AS "VAT to Remit (Non-OSS)",
translate(to_char(a.gross_excl_b2b, 'FM999,999,999,999.99'), ',.', '.,') || ' €' AS "Gross Sales (Excl. B2B)",
translate(to_char(COALESCE(i.intra_net, 0), 'FM999,999,999,999.99'), ',.', '.,') || ' €'
AS "Intra-community supply – Net sales"
FROM arrival a
LEFT JOIN intra i ON i.country_code = a.country_code
LEFT JOIN res_country c ON c.code = a.country_code
ORDER BY a.country_code;
-- ----------------------------------------------------------------------------
-- Import-Kontrolle (entspricht der LET-Pruefung im Sheet):
-- zaehlt Datensaetze, deren Betragsspalten nicht als Zahl vorliegen.
--
-- SELECT count(*) AS nicht_numerische_zellen
-- FROM brandize_datacenter_amazon_vat_transaction
-- WHERE activity_period = %(month)s
-- AND (total_activity_value_amt_vat_incl IS NULL
-- OR total_activity_value_vat_amt IS NULL);
--
-- Hinweis: Die Zeilenreihenfolge im Sheet ist die Erstauftretens-Reihenfolge
-- (UNIQUE(FILTER(...))). Falls gewuenscht, statt "ORDER BY a.country_code"
-- ein "MIN(id) AS first_seen" in der arrival-CTE ergaenzen und danach sortieren.
-- ----------------------------------------------------------------------------
Executable
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
#!/bin/bash
# Simple static file server with live reload
# Requires Node.js and live-server
# Check if live-server is installed
if ! command -v live-server &> /dev/null
then
echo "Installing live-server..."
npm install -g live-server
fi
# Serve the current directory with auto-reload
echo "Starting live-server on http://localhost:8080 ..."
live-server .
+467
View File
@@ -0,0 +1,467 @@
:root {
--bg: #f4f6fb;
--surface: #ffffff;
--surface-2: #f8fafc;
--border: #e6e9f0;
--text: #1b2333;
--muted: #6b7488;
--accent: #4f46e5;
--accent-soft: #eef0ff;
--sidebar-bg: #ffffff;
--shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 8px 24px rgba(16, 24, 40, .06);
--radius: 16px;
--sidebar-w: 264px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background:
radial-gradient(1200px 600px at 100% -10%, #e8ecff 0%, transparent 55%),
var(--bg);
color: var(--text);
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
}
/* ================= Layout ================= */
.layout {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
min-height: 100vh;
}
/* ================= Sidebar ================= */
.sidebar {
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
padding: 20px 16px;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 8px 20px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.brand-mark {
width: 40px; height: 40px;
border-radius: 12px;
display: grid; place-items: center;
font-weight: 700; font-size: 19px; color: #fff;
background: linear-gradient(135deg, #6366f1, #4f46e5 60%, #4338ca);
box-shadow: 0 6px 16px rgba(79, 70, 229, .35);
flex: none;
}
.brand-text { display: flex; flex-direction: column; line-height: 1.25; }
.brand-text strong { font-size: 15px; letter-spacing: -.01em; }
.brand-text span { font-size: 12px; color: var(--muted); }
/* ---------- Menu ---------- */
.menu { display: flex; flex-direction: column; gap: 4px; }
.menu-group { border-radius: 12px; }
.menu-main {
width: 100%;
display: flex;
align-items: center;
gap: 11px;
padding: 9px 10px;
border: none;
background: transparent;
border-radius: 12px;
cursor: pointer;
color: var(--text);
font-size: 14.5px;
font-weight: 600;
text-align: left;
transition: background .15s;
}
.menu-main:hover { background: var(--surface-2); }
.menu-logo { width: 30px; height: 30px; flex: none; display: block; }
.menu-logo svg { width: 100%; height: 100%; display: block; border-radius: 8px; }
.menu-name { flex: 1; }
.chevron {
width: 16px; height: 16px;
color: var(--muted);
transition: transform .22s ease;
}
.menu-group.open .chevron { transform: rotate(90deg); }
/* Submenu: hidden by default, expands when the group is open */
.submenu {
overflow: hidden;
max-height: 0;
transition: max-height .24s ease;
}
.menu-group.open .submenu { max-height: 240px; }
.submenu-item {
display: block;
margin: 2px 0 2px 41px;
padding: 8px 12px;
border-radius: 9px;
font-size: 13.5px;
color: var(--muted);
text-decoration: none;
border-left: 2px solid transparent;
transition: background .15s, color .15s, border-color .15s;
}
.submenu-item:hover { background: var(--surface-2); color: var(--text); }
.submenu-item.is-active {
background: var(--accent-soft);
color: var(--accent);
font-weight: 600;
border-left-color: var(--accent);
}
/* ================= Main ================= */
.main {
min-width: 0;
padding: 26px 30px 64px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 24px;
}
.page-title h1 { margin: 0; font-size: 22px; letter-spacing: -.02em; }
.page-title p { margin: 3px 0 0; color: var(--muted); font-size: 13.5px; }
/* ---------- Shared filter bar ---------- */
.filterbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
background: var(--surface);
border: 1px solid var(--border);
border-left: 4px solid var(--accent);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 14px 18px;
margin-bottom: 22px;
}
.filter-head { display: flex; align-items: center; gap: 9px; }
.filter-icon { width: 18px; height: 18px; color: var(--accent); flex: none; }
.filter-title { font-weight: 700; font-size: 14px; letter-spacing: -.01em; }
.filter-hint { font-size: 12px; color: var(--muted); }
.filter-fields { display: flex; align-items: flex-end; gap: 10px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field-label {
font-size: 11px; font-weight: 600; letter-spacing: .06em;
text-transform: uppercase; color: var(--muted);
}
.period { display: flex; gap: 8px; }
select {
appearance: none;
border: 1px solid var(--border);
background: var(--surface) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7488' stroke-width='2.5'><path d='M6 9l6 6 6-6'/></svg>") no-repeat right 12px center;
padding: 10px 34px 10px 14px;
border-radius: 11px;
font-size: 14px;
font-weight: 600;
color: var(--text);
cursor: pointer;
box-shadow: var(--shadow);
transition: border-color .15s, box-shadow .15s;
}
select:hover { border-color: #cfd5e4; }
select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.refresh {
width: 42px; height: 42px;
display: grid; place-items: center;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 11px;
color: var(--muted);
cursor: pointer;
box-shadow: var(--shadow);
transition: color .15s, border-color .15s, transform .15s;
}
.refresh:hover { color: var(--accent); border-color: #cfd5e4; }
.refresh:active { transform: scale(.94); }
.refresh svg { fill: currentColor; }
.refresh.spinning svg { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.download {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 11px;
color: var(--text);
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--shadow);
transition: color .15s, border-color .15s, transform .15s, opacity .15s;
}
.download:hover:not(:disabled) { color: var(--accent); border-color: #cfd5e4; }
.download:active:not(:disabled) { transform: scale(.97); }
.download:disabled { opacity: .45; cursor: not-allowed; }
.download svg { stroke: currentColor; }
/* ---------- KPI cards ---------- */
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 22px;
}
.kpi {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.kpi::after {
content: "";
position: absolute; inset: 0 auto 0 0; width: 4px;
background: var(--accent);
opacity: .85;
}
.kpi.primary { background: linear-gradient(135deg, #4f46e5, #4338ca); border-color: transparent; color: #fff; }
.kpi.primary::after { display: none; }
.kpi.primary .kpi-label { color: rgba(255,255,255,.8); }
.kpi.primary .kpi-value { color: #fff; }
.kpi-label {
font-size: 12px; font-weight: 600; letter-spacing: .05em;
text-transform: uppercase; color: var(--muted);
}
.kpi-value { font-size: 26px; font-weight: 700; letter-spacing: -.02em; margin-top: 6px; }
.kpi-sub { font-size: 12.5px; color: var(--muted); margin-top: 4px; }
.kpi.primary .kpi-sub { color: rgba(255,255,255,.75); }
/* ---------- Panels ---------- */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px 22px;
margin-bottom: 22px;
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap; margin-bottom: 18px;
}
.panel-head h2 { margin: 0; font-size: 16px; letter-spacing: -.01em; }
.muted { color: var(--muted); font-size: 13px; }
.legend { display: flex; gap: 14px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted); }
.legend-dot { width: 10px; height: 10px; border-radius: 3px; }
/* ---------- Bar chart ---------- */
.chart { display: flex; flex-direction: column; gap: 14px; }
.bar-row { display: grid; grid-template-columns: 46px 1fr 160px; align-items: center; gap: 14px; }
.bar-country {
font-weight: 700; font-size: 13px; text-align: center;
color: var(--text);
}
.bar-track {
position: relative;
background: var(--surface-2);
border-radius: 10px;
height: 34px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 10px;
width: 0;
transition: width .7s cubic-bezier(.22,1,.36,1);
min-width: 2px;
}
.bar-value {
font-size: 12.5px; font-weight: 600; color: var(--muted);
text-align: right; white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* ---------- Table ---------- */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
thead th {
text-align: left; font-size: 11.5px; letter-spacing: .05em;
text-transform: uppercase; color: var(--muted);
padding: 10px 12px; border-bottom: 1px solid var(--border);
font-weight: 600;
}
tbody td { padding: 11px 12px; border-bottom: 1px solid var(--surface-2); }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: var(--surface-2); }
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; }
tr.total td { font-weight: 700; background: var(--accent-soft); }
tr.total:hover td { background: var(--accent-soft); }
.pill {
display: inline-block; padding: 2px 9px; border-radius: 999px;
font-size: 12px; font-weight: 600;
background: var(--accent-soft); color: var(--accent);
}
.share {
display: inline-flex; align-items: center; gap: 8px; justify-content: flex-end;
}
.share-bar { width: 60px; height: 6px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.share-bar > i { display: block; height: 100%; background: var(--accent); border-radius: 999px; }
/* ---------- Placeholder view ---------- */
.placeholder { text-align: center; padding: 60px 24px; }
.placeholder-icon { width: 56px; height: 56px; margin: 0 auto 16px; }
.placeholder-icon svg { width: 100%; height: 100%; border-radius: 14px; }
.placeholder h2 { margin: 0 0 6px; font-size: 18px; }
/* ---------- States ---------- */
.state { text-align: center; color: var(--muted); padding: 8px; }
.state.error {
background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca;
border-radius: 12px; padding: 14px 18px; margin-top: 8px;
}
.hidden { display: none !important; }
.skeleton {
height: 34px; border-radius: 10px;
background: linear-gradient(90deg, #eef1f7 25%, #e3e8f2 37%, #eef1f7 63%);
background-size: 400% 100%;
animation: shimmer 1.3s ease infinite;
}
@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
/* ================= Auth prompt ================= */
.auth-overlay {
position: fixed;
inset: 0;
background: rgba(20, 26, 40, .45);
backdrop-filter: blur(3px);
display: grid;
place-items: center;
padding: 20px;
z-index: 50;
}
.auth-card {
width: 100%;
max-width: 380px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(16, 24, 40, .25);
padding: 26px 24px;
text-align: center;
}
.auth-icon {
width: 52px; height: 52px;
margin: 0 auto;
display: grid; place-items: center;
border-radius: 14px;
background: var(--accent-soft);
color: var(--accent);
}
.auth-icon svg { width: 26px; height: 26px; }
.auth-card h2 { margin: 14px 0 6px; font-size: 18px; letter-spacing: -.01em; }
.auth-card p.muted { margin: 0 0 18px; }
.auth-field { display: block; text-align: left; margin-bottom: 12px; }
.auth-field span {
display: block;
font-size: 11.5px; font-weight: 600; letter-spacing: .05em;
text-transform: uppercase; color: var(--muted);
margin-bottom: 6px;
}
.auth-field input {
width: 100%;
padding: 11px 13px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 14px;
font-family: inherit;
color: var(--text);
background: var(--surface-2);
transition: border-color .15s, box-shadow .15s, background .15s;
}
.auth-field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
background: #fff;
}
.auth-error { color: #b91c1c; font-size: 13px; margin: 4px 0 12px; }
.auth-actions { display: flex; gap: 10px; margin-top: 18px; }
.btn-primary, .btn-ghost {
flex: 1;
padding: 11px 16px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
border: 1px solid transparent;
transition: background .15s, border-color .15s;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #4338ca; }
.btn-ghost { background: var(--surface); border-color: var(--border); color: var(--text); }
.btn-ghost:hover { background: var(--surface-2); }
/* ================= Responsive ================= */
@media (max-width: 820px) {
.layout { grid-template-columns: 1fr; }
.sidebar {
position: static;
height: auto;
border-right: none;
border-bottom: 1px solid var(--border);
}
.main { padding: 20px 16px 48px; }
}
@media (max-width: 560px) {
.kpi-value { font-size: 22px; }
.bar-row { grid-template-columns: 40px 1fr 110px; gap: 10px; }
.bar-value { font-size: 11.5px; }
}