From 1ea3de74f255193ff3ea96812ca5d3262aec0712 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 9 Oct 2026 15:01:40 -0300 Subject: [PATCH] INIT --- .gitignore | 1 + README.md | 77 ++++ app.js | 699 +++++++++++++++++++++++++++++++++ index.html | 246 ++++++++++++ send | 114 ++++++ sql/Delete_Activity_Period.sql | 40 ++ sql/Summe_je_Abgangsland.sql | 55 +++ sql/VAT_Summary.sql | 105 +++++ start | 21 + styles.css | 467 ++++++++++++++++++++++ 10 files changed, 1825 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.js create mode 100644 index.html create mode 100755 send create mode 100644 sql/Delete_Activity_Period.sql create mode 100644 sql/Summe_je_Abgangsland.sql create mode 100644 sql/VAT_Summary.sql create mode 100755 start create mode 100644 styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fce603 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd7c324 --- /dev/null +++ b/README.md @@ -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 | diff --git a/app.js b/app.js new file mode 100644 index 0000000..709ec84 --- /dev/null +++ b/app.js @@ -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 = ` +
+
Gesamtumsatz
+
${formatEuro(total)}
+
Brutto inkl. USt
+
+
+
Abgangsländer
+
${countries.length}
+
mit Umsatz im Zeitraum
+
+
+
Stärkstes Land
+
${top ? top.land : "–"}
+
${top ? formatEuro(top.brutto) : "keine Daten"}
+
+
+
Ø je Land
+
${formatEuro(avg)}
+
Durchschnitt
+
`; +} + +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 = '

Keine Daten für diesen Zeitraum.

'; + 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 = ` +
${c.land}
+
+
+
+
${formatEuro(c.brutto)} · ${formatPercent(share)}
`; + 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 = `${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 = ` + ${c.bereich} + ${c.land} + ${formatEuro(c.brutto)} + + + + ${formatPercent(share)} + + `; + els.table.appendChild(tr); + }); + + const tr = document.createElement("tr"); + tr.className = "total"; + tr.innerHTML = ` + Gesamt + + ${formatEuro(total)} + 100,0 %`; + 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 = ` +
+
Gross Sales (Total)
+
${formatEuro(totals.grossTotal)}
+
Summe aller Länder
+
+
+
VAT to Remit (Non-OSS)
+
${formatEuro(totals.vatToRemit)}
+
abzuführende USt
+
+
+
Länder
+
${countries.length}
+
mit Umsatz im Zeitraum
+
+
+
Menge
+
${formatQuantity(totals.quantity)}
+
Artikel gesamt
+
`; +} + +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 = '

Keine Daten für diesen Zeitraum.

'; + 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 = ` +
${c.code}
+
+
+
+
${formatEuro(c.grossTotal)} · ${formatPercent(share)}
`; + 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 = ` + ${c.code} ${c.land} + ${formatQuantity(c.quantity)} + ${formatEuro(c.grossTotal)} + ${formatEuro(c.grossNonOss)} + ${formatEuro(c.vatToRemit)} + ${formatEuro(c.grossExclB2b)} + ${formatEuro(c.intraSupply)}`; + els.vatTable.appendChild(tr); + }); + + const tr = document.createElement("tr"); + tr.className = "total"; + tr.innerHTML = ` + Gesamt + ${formatQuantity(totals.quantity)} + ${formatEuro(totals.grossTotal)} + ${formatEuro(totals.grossNonOss)} + ${formatEuro(totals.vatToRemit)} + ${formatEuro(totals.grossExclB2b)} + ${formatEuro(totals.intraSupply)}`; + 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(() => '
') + .join(""); +} + +function skeletonBars() { + return Array.from({ length: 5 }).map(() => '
').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(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..0279258 --- /dev/null +++ b/index.html @@ -0,0 +1,246 @@ + + + + + + Brandize · BI Dashboard + + + +
+ + + + +
+
+
+

Umsatz je Abgangsland

+

Amazon · Brutto (inkl. USt)

+
+ + +
+ + +
+
+ + Filter + gilt für alle Dashboards +
+ +
+ + +
+
+ + +
+
+ +
+
+

Umsatz je Abgangsland

+
+
+
+
+ +
+
+

Details

+ +
+
+ + + + + + + + + + +
BereichAbgangslandBrutto (inkl. USt)Anteil
+
+
+ +
+
+ + +
+
+ +
+
+

Gross Sales (Total) je Land

+
+
+
+
+ +
+
+

Details

+ +
+
+ + + + + + + + + + + + + +
LandMengeGross Sales (Total)Gross Sales (Non-OSS)VAT to Remit (Non-OSS)Gross Sales (Excl. B2B)Intra-community supply – Net sales
+
+
+ +
+
+ + +
+
+
+ +
+

ODOO Dashboard

+

Dieses Dashboard folgt in Kürze.

+
+
+
+
+ + + + + + + diff --git a/send b/send new file mode 100755 index 0000000..e9ef765 --- /dev/null +++ b/send @@ -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() diff --git a/sql/Delete_Activity_Period.sql b/sql/Delete_Activity_Period.sql new file mode 100644 index 0000000..65925a1 --- /dev/null +++ b/sql/Delete_Activity_Period.sql @@ -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. +-- ---------------------------------------------------------------------------- diff --git a/sql/Summe_je_Abgangsland.sql b/sql/Summe_je_Abgangsland.sql new file mode 100644 index 0000000..61d8496 --- /dev/null +++ b/sql/Summe_je_Abgangsland.sql @@ -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; diff --git a/sql/VAT_Summary.sql b/sql/VAT_Summary.sql new file mode 100644 index 0000000..6b57d9c --- /dev/null +++ b/sql/VAT_Summary.sql @@ -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. +-- ---------------------------------------------------------------------------- diff --git a/start b/start new file mode 100755 index 0000000..a1db1d4 --- /dev/null +++ b/start @@ -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 . + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..147263e --- /dev/null +++ b/styles.css @@ -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,") 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; } +}