INIT
This commit is contained in:
commit
1ea3de74f2
10 files changed
+1825
No files matched your search
@@ -0,0 +1 @@
|
||||
data/
|
||||
@@ -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 |
|
||||
@@ -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
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
-- ----------------------------------------------------------------------------
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
-- ----------------------------------------------------------------------------
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user