not bad
This commit is contained in:
1 parent
1ea3de74f2
commit
9db080faea
8 files changed
+2064
-55
No files matched your search
@@ -0,0 +1,133 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Guidance for agents working in this repository (Brandize · BI Dashboard).
|
||||||
|
|
||||||
|
## What this project is
|
||||||
|
|
||||||
|
A **flat, server-free browser dashboard** for the Brandize BI webhook. It renders
|
||||||
|
gross revenue (Brutto inkl. USt) per departure country plus a VAT summary, with KPI
|
||||||
|
cards, bar charts and detail tables. There is **no build step, no framework, no
|
||||||
|
package manager** — just static files opened directly in a browser.
|
||||||
|
|
||||||
|
Supporting tools: a bash dev-server launcher (`start`) and a Python CLI (`send`) that
|
||||||
|
uploads CSV rows to an n8n webhook. SQL scripts (`sql/`) define the upstream queries
|
||||||
|
that feed the webhook (Odoo 17 / PostgreSQL).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `index.html` | Dashboard markup: sidebar nav, shared filter bar, one `<main class="view">` per report, auth overlay |
|
||||||
|
| `styles.css` | All styling (CSS custom properties in `:root`, BEM-ish class names) |
|
||||||
|
| `app.js` | Everything: data loading, parsing, rendering, CSV export, auth, navigation |
|
||||||
|
| `README.md` | User-facing docs (German/English mixed). Update it when behavior changes |
|
||||||
|
| `start` | Bash: installs `live-server` if missing and serves the folder on :8080 |
|
||||||
|
| `send` | Python CLI: batches CSV rows to the `.../webhook/VAT` n8n webhook |
|
||||||
|
| `sql/*.sql` | Upstream Odoo/PostgreSQL queries (see below) |
|
||||||
|
| `data/` | Git-ignored. Empty in the repo; holds local CSV payloads for `send` |
|
||||||
|
|
||||||
|
## Running / validating
|
||||||
|
|
||||||
|
There is **no test suite and no linter**. Validation is manual:
|
||||||
|
|
||||||
|
- Open `index.html` directly (double-click) — the intended primary usage, works over `file://`.
|
||||||
|
- Or run `./start` and browse `http://localhost:8080` for live reload.
|
||||||
|
- `./send data/<file>.csv` requires Python 3 + `requests` and network access to the webhook.
|
||||||
|
|
||||||
|
Because `fetch` hits a remote webhook protected by HTTP Basic Auth, you generally
|
||||||
|
**cannot fully exercise the UI without valid credentials and network access**. Don't
|
||||||
|
claim validation passed unless you actually loaded the page and saw it work; otherwise
|
||||||
|
state clearly what you could not run.
|
||||||
|
|
||||||
|
## Architecture notes (`app.js`)
|
||||||
|
|
||||||
|
Single file, loaded as a plain `<script>` (not a module), `"use strict"` at the top.
|
||||||
|
No bundler — keep everything in global scope as the file already does.
|
||||||
|
|
||||||
|
- **Config constants** at the top: `API_URL`, `MONTHS` (German abbreviations), `PALETTE`.
|
||||||
|
- **`els`** — a single object caching all `getElementById`/`querySelector` lookups.
|
||||||
|
Add new DOM handles here rather than querying inline.
|
||||||
|
- **State**: `currentView`, `lastData` (`{ abgangsland, vat }`), `auth`.
|
||||||
|
- **Sections** are delimited by `/* ---------- Name ---------- */` banners: Auth,
|
||||||
|
Period selector, Formatting, Data, Rendering (per report), CSV export, Navigation, Init.
|
||||||
|
- **Views**: the `VIEWS` map defines title/subtitle per view id. `showView(name)`
|
||||||
|
toggles `.hidden` on the `<main class="view">` elements and re-renders. A new report
|
||||||
|
needs: markup section in `index.html`, an entry in `VIEWS`, a render function, and a
|
||||||
|
`viewEls()` branch.
|
||||||
|
- **Data flow**: `load()` → `POST API_URL` with `{ query, month }` → `extractArray(payload, key)`
|
||||||
|
→ `normalizeAbgangsland` / `normalizeVat` → `renderActiveView()`. `load()` queries
|
||||||
|
**only** the active dashboard's dataset; it is triggered from `showView()` (when a
|
||||||
|
dashboard is displayed) and from the refresh/period controls — never prefetched.
|
||||||
|
|
||||||
|
### Webhook / data contract
|
||||||
|
|
||||||
|
- The webhook is called with **POST** and a JSON body `{ "query": <key>, "month": "YYYY-MMM" }`
|
||||||
|
(quarter dashboards send `{ "query": <key>, "quarter": "YYYY-QN" }` instead).
|
||||||
|
`query` selects the dataset. The datasets are large, so **exactly one dataset is
|
||||||
|
requested per call**, for the dashboard currently shown (no prefetching).
|
||||||
|
- Query keys: `"Summe je Abgangsland"` (Umsatz je Abgangsland), `"VAT Summary"`,
|
||||||
|
`"ODOO Rechnungen pro Monat"`, `"Alle Rechnungen pro Monat"` and `"Quartal Schweiz"`.
|
||||||
|
Each view's `query` lives in the `VIEWS` map, which maps the active dashboard to its
|
||||||
|
`normalize` function; `quarter: true` marks the dashboards that use a quarter period.
|
||||||
|
- `extractArray` accepts the dataset either as a bare array or wrapped under the
|
||||||
|
query key (case-insensitive). If the shape changes upstream, adjust the normalizers.
|
||||||
|
- The webhook returns **German-formatted strings**, e.g. `"90.717,93 €"` and
|
||||||
|
`"1.234"`. Parsing (`parseEuro`, `parseQuantity`) strips symbols and swaps
|
||||||
|
`.`/`,` — do **not** use `parseFloat` directly on the raw values.
|
||||||
|
- `month` uses `YYYY-MMM` (e.g. `2026-SEP`), matching `%(month)s` in `sql/*.sql`;
|
||||||
|
`quarter` uses `YYYY-QN` (e.g. `2026-Q3`), matching `%(quarter)s` in `sql/Quartal_Schweiz.sql`.
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
HTTP Basic. The first request goes out without credentials; a `401` triggers the
|
||||||
|
login overlay. Credentials live in `sessionStorage` (best-effort — unavailable on
|
||||||
|
`file://`, where they stay in memory only) and are sent as `Authorization: Basic ...`.
|
||||||
|
|
||||||
|
## SQL scripts (`sql/`)
|
||||||
|
|
||||||
|
Odoo 17 / PostgreSQL against table `brandize_datacenter_amazon_vat_transaction`.
|
||||||
|
Each file has a header block documenting the model, parameters and invocation.
|
||||||
|
|
||||||
|
- Parameter is named `%(month)s` for psycopg2; in `psql` use `:'month'` instead
|
||||||
|
(documented in the headers — respect this distinction).
|
||||||
|
- Money is formatted **in SQL** to German notation via
|
||||||
|
`translate(to_char(...), ',.', '.,') || ' €'` so the frontend receives display-ready
|
||||||
|
strings. Keep formatting consistent if you add columns.
|
||||||
|
- `Delete_Activity_Period.sql` is destructive — note the "ACHTUNG" warning and the
|
||||||
|
transaction-bracket guidance. Be cautious editing it.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Language**: UI text is **German**, code identifiers and comments are English
|
||||||
|
(comments occasionally German). Keep new user-facing strings German.
|
||||||
|
- **Number/date formatting**: always `de-DE` via `toLocaleString` in JS and German
|
||||||
|
`to_char` formatting in SQL. Dates as `YYYY-MMM` with the `MONTHS` abbreviations.
|
||||||
|
- **JS style**: `"use strict"`, double quotes, semicolons, 2-space indent, `const`/`let`
|
||||||
|
(no `var`), named `function` declarations, `camelCase`. Nullish-safe (`??`, optional
|
||||||
|
guards). DOM built imperatively with `document.createElement` (charts are hand-rolled
|
||||||
|
bars/lists, no chart library).
|
||||||
|
- **CSS**: use the existing `:root` custom properties for colors/spacing/radius; reuse
|
||||||
|
existing class patterns rather than inventing new naming.
|
||||||
|
- **CSV export**: semicolon-delimited, UTF-8 **with BOM** (for Excel umlauts), CRLF line
|
||||||
|
endings, includes a `Gesamt` total row, filename `*_YYYY-MMM.csv`. Follow this pattern
|
||||||
|
for any new exportable view.
|
||||||
|
- Keep changes minimal and consistent with the existing file structure — this is a small,
|
||||||
|
intentionally dependency-free static app. Prefer no new dependencies.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `data/` is git-ignored; never commit contents from it. `send` expects CSVs with a
|
||||||
|
header row (`csv.DictReader`) and posts JSON arrays in batches of 50.
|
||||||
|
- The `send` script targets a **different** webhook (`/webhook/VAT`) than the dashboard
|
||||||
|
(`/webhook/bi-dashboard`). Don't conflate them.
|
||||||
|
- `fetch` from `file://` sends `Origin: null`; the webhook reflects origins to allow CORS.
|
||||||
|
Changing how requests are made can break the "just open the file" workflow.
|
||||||
|
- README documents behavior (navigation, auth, CSV, filter). If you change any of these,
|
||||||
|
update `README.md` in the same change.
|
||||||
|
- The invoice dashboards (*ODOO Rechnungen pro Monat*, *Alle Rechnungen pro Monat*
|
||||||
|
and *Quartal Schweiz*) are pivot experiments: they load jQuery, jQuery UI and
|
||||||
|
PivotTable.js from CDNs (so they need internet), reuse `normalizeRechnungen`, and
|
||||||
|
render via `renderRechnungenPivot` (no detail table). Layouts come from
|
||||||
|
`PIVOT_PRESETS` (keyed per view; the Vorlage dropdown) / the pivotUI panel; the
|
||||||
|
renderer is fixed to *Table*. The pivot container is recreated on each render so
|
||||||
|
a preset actually applies.
|
||||||
@@ -15,6 +15,12 @@ The page calls the webhook directly:
|
|||||||
https://brandize.app.n8n.cloud/webhook/bi-dashboard
|
https://brandize.app.n8n.cloud/webhook/bi-dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
via a **POST** request with a JSON body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "query": "Summe je Abgangsland", "month": "2026-SEP" }
|
||||||
|
```
|
||||||
|
|
||||||
The webhook sends CORS headers (it reflects the request `Origin`, including
|
The webhook sends CORS headers (it reflects the request `Origin`, including
|
||||||
`null` for `file://`), so a direct browser call works.
|
`null` for `file://`), so a direct browser call works.
|
||||||
|
|
||||||
@@ -24,9 +30,11 @@ The left sidebar groups reports by topic:
|
|||||||
|
|
||||||
- **Amazon** → *Umsatz je Abgangsland* (Brutto inkl. USt je Abgangsland)
|
- **Amazon** → *Umsatz je Abgangsland* (Brutto inkl. USt je Abgangsland)
|
||||||
- **Amazon** → *VAT Summary* (USt-Übersicht je Land)
|
- **Amazon** → *VAT Summary* (USt-Übersicht je Land)
|
||||||
- **ODOO** → *Dashboard* (placeholder, to follow)
|
- **ODOO** → *Rechnungen pro Monat* (Pivot über Odoo-Rechnungen)
|
||||||
|
- **Gesamt** → *Alle Rechnungen pro Monat* (PivotTable.js-Experiment)
|
||||||
|
- **Schweiz** → *Quartal Schweiz* (AMAZON + ODOO, Lieferung in die Schweiz)
|
||||||
|
|
||||||
Both groups start collapsed, so no submenu is visible when the page opens.
|
All groups start collapsed, so no submenu is visible when the page opens.
|
||||||
Clicking a topic expands its submenu; clicking a submenu item switches the
|
Clicking a topic expands its submenu; clicking a submenu item switches the
|
||||||
view.
|
view.
|
||||||
|
|
||||||
@@ -49,8 +57,11 @@ Gesamt row:
|
|||||||
|
|
||||||
- *Umsatz je Abgangsland* → `umsatz-je-abgangsland_YYYY-MMM.csv`
|
- *Umsatz je Abgangsland* → `umsatz-je-abgangsland_YYYY-MMM.csv`
|
||||||
- *VAT Summary* → `vat-summary_YYYY-MMM.csv`
|
- *VAT Summary* → `vat-summary_YYYY-MMM.csv`
|
||||||
|
- *Rechnungen pro Monat* → `odoo-rechnungen_YYYY-MMM.csv`
|
||||||
|
- *Alle Rechnungen pro Monat* → `gesamt-rechnungen-pivot_YYYY-MMM.csv`
|
||||||
|
- *Quartal Schweiz* → `schweiz-quartal_YYYY-QN.csv`
|
||||||
|
|
||||||
The button is disabled on dashboards without data (e.g. the ODOO placeholder).
|
The button is disabled while no dataset is loaded for the current dashboard.
|
||||||
|
|
||||||
## Filter
|
## Filter
|
||||||
|
|
||||||
@@ -58,15 +69,55 @@ 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**
|
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
|
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
|
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
|
sent to the webhook in the POST body as `month` (e.g. `2026-SEP`), matching the
|
||||||
used in `../sql/Summe_je_Abgangsland.sql`.
|
`%(month)s` parameter used in `../sql/*.sql`.
|
||||||
|
|
||||||
|
The invoice dashboards (*ODOO Rechnungen pro Monat*, *Alle Rechnungen pro Monat*
|
||||||
|
and *Quartal Schweiz*) have an additional, dashboard-specific filter row. *ODOO
|
||||||
|
Rechnungen pro Monat* filters by **Tag**, **Bestellnummer**, **Lieferland** and
|
||||||
|
**Rechnungsnummer**; *Alle Rechnungen pro Monat* and *Quartal Schweiz* add a
|
||||||
|
**Quelle** field. Filtering re-renders the KPIs, chart and pivot live.
|
||||||
|
|
||||||
|
For *Quartal Schweiz* the shared **Zeitraum** control shows a **quarter**
|
||||||
|
selector (Q1–Q4) instead of the month selector; the year selector is shared.
|
||||||
|
|
||||||
|
## Pivot (Experiment)
|
||||||
|
|
||||||
|
The three invoice dashboards (*ODOO Rechnungen pro Monat*, *Alle Rechnungen pro
|
||||||
|
Monat* and *Quartal Schweiz*) render their data as an interactive
|
||||||
|
[PivotTable.js](https://pivottable.js.org/) pivot instead of a detail table.
|
||||||
|
The pivot's rows, columns, values and aggregator are freely configurable in the
|
||||||
|
pivotUI control panel; the renderer is fixed to *Table*. Each dashboard has its
|
||||||
|
own filter row and **Vorlage** dropdown with presets:
|
||||||
|
|
||||||
|
- *ODOO Rechnungen pro Monat*: *Standard* (**Rechnungsland** rows, no column
|
||||||
|
group, **Sum(Gesamtpreis brutto)**) and *Zahlart*.
|
||||||
|
- *Alle Rechnungen pro Monat* and *Quartal Schweiz*: *Standard* (**Quelle**
|
||||||
|
rows, no column group, **Sum(Gesamtpreis brutto)**) and *Rechnungsland
|
||||||
|
(Spalten)* (adds **Rechnungsland** as the column group).
|
||||||
|
|
||||||
|
*Quartal Schweiz* is filtered to invoices with a **delivery into Switzerland**.
|
||||||
|
|
||||||
|
Applying a preset also resets the filters. The chosen layout is kept while you
|
||||||
|
type in the filters.
|
||||||
|
|
||||||
|
It loads jQuery, jQuery UI and PivotTable.js from public CDNs, so — unlike the
|
||||||
|
rest of the app — it needs an **internet connection** to render; offline it shows
|
||||||
|
a short message instead. To vendor the libraries locally, download them next to
|
||||||
|
`index.html` and change the `<link>`/`<script>` URLs in the `<head>`.
|
||||||
|
|
||||||
## Data sources
|
## Data sources
|
||||||
|
|
||||||
One request to the webhook returns both datasets, which are read by key:
|
Because the datasets are large, the dashboard requests **only the dataset of
|
||||||
|
the currently displayed dashboard** — one POST per dashboard, sent only when
|
||||||
|
that dashboard is opened or reloaded. Nothing is prefetched. The dataset is
|
||||||
|
selected with the `query` field:
|
||||||
|
|
||||||
- `Summe je Abgangsland` → the *Umsatz je Abgangsland* report
|
- *Umsatz je Abgangsland* → `query: "Summe je Abgangsland"`
|
||||||
- `VAT Summary` → the *VAT Summary* report
|
- *VAT Summary* → `query: "VAT Summary"`
|
||||||
|
- *Rechnungen pro Monat* → `query: "ODOO Rechnungen pro Monat"`
|
||||||
|
- *Alle Rechnungen pro Monat* → `query: "Alle Rechnungen pro Monat"`
|
||||||
|
- *Quartal Schweiz* → `query: "Quartal Schweiz"` (sends `quarter` instead of `month`)
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const PALETTE = ["#4f46e5", "#0ea5e9", "#f59e0b", "#10b981", "#ec4899", "#8b5cf6
|
|||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
month: document.getElementById("month-select"),
|
month: document.getElementById("month-select"),
|
||||||
|
quarter: document.getElementById("quarter-select"),
|
||||||
year: document.getElementById("year-select"),
|
year: document.getElementById("year-select"),
|
||||||
refresh: document.getElementById("refresh-btn"),
|
refresh: document.getElementById("refresh-btn"),
|
||||||
download: document.getElementById("download-btn"),
|
download: document.getElementById("download-btn"),
|
||||||
@@ -23,6 +24,37 @@ const els = {
|
|||||||
vatTable: document.querySelector("#vat-table tbody"),
|
vatTable: document.querySelector("#vat-table tbody"),
|
||||||
vatPeriod: document.getElementById("vat-period"),
|
vatPeriod: document.getElementById("vat-period"),
|
||||||
vatState: document.getElementById("vat-state"),
|
vatState: document.getElementById("vat-state"),
|
||||||
|
// ODOO · Rechnungen pro Monat
|
||||||
|
odooKpis: document.getElementById("odoo-kpis"),
|
||||||
|
odooChart: document.getElementById("odoo-chart"),
|
||||||
|
odooLegend: document.getElementById("odoo-legend"),
|
||||||
|
odooPeriod: document.getElementById("odoo-period"),
|
||||||
|
odooState: document.getElementById("odoo-state"),
|
||||||
|
odooPivotPreset: document.getElementById("odoo-pivot-preset"),
|
||||||
|
odooFilterTag: document.getElementById("odoo-filter-tag"),
|
||||||
|
odooFilterBestellnummer: document.getElementById("odoo-filter-bestellnummer"),
|
||||||
|
odooFilterLieferland: document.getElementById("odoo-filter-lieferland"),
|
||||||
|
odooFilterNummer: document.getElementById("odoo-filter-rechnungsnummer"),
|
||||||
|
// Gesamt · Alle Rechnungen pro Monat (Pivot, Experiment)
|
||||||
|
pivotKpis: document.getElementById("pivot-kpis"),
|
||||||
|
pivotPeriod: document.getElementById("pivot-period"),
|
||||||
|
pivotState: document.getElementById("pivot-state"),
|
||||||
|
pivotPreset: document.getElementById("pivot-preset"),
|
||||||
|
pivotFilterTag: document.getElementById("pivot-filter-tag"),
|
||||||
|
pivotFilterBestellnummer: document.getElementById("pivot-filter-bestellnummer"),
|
||||||
|
pivotFilterLieferland: document.getElementById("pivot-filter-lieferland"),
|
||||||
|
pivotFilterNummer: document.getElementById("pivot-filter-rechnungsnummer"),
|
||||||
|
pivotFilterQuelle: document.getElementById("pivot-filter-quelle"),
|
||||||
|
// Schweiz · Quartal Schweiz
|
||||||
|
chKpis: document.getElementById("ch-kpis"),
|
||||||
|
chPeriod: document.getElementById("ch-period"),
|
||||||
|
chState: document.getElementById("ch-state"),
|
||||||
|
chPreset: document.getElementById("ch-pivot-preset"),
|
||||||
|
chFilterTag: document.getElementById("ch-filter-tag"),
|
||||||
|
chFilterBestellnummer: document.getElementById("ch-filter-bestellnummer"),
|
||||||
|
chFilterLieferland: document.getElementById("ch-filter-lieferland"),
|
||||||
|
chFilterNummer: document.getElementById("ch-filter-rechnungsnummer"),
|
||||||
|
chFilterQuelle: document.getElementById("ch-filter-quelle"),
|
||||||
// Auth
|
// Auth
|
||||||
authOverlay: document.getElementById("auth-overlay"),
|
authOverlay: document.getElementById("auth-overlay"),
|
||||||
authForm: document.getElementById("auth-form"),
|
authForm: document.getElementById("auth-form"),
|
||||||
@@ -33,8 +65,10 @@ const els = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// State for the currently displayed dashboard and its last loaded data.
|
// State for the currently displayed dashboard and its last loaded data.
|
||||||
|
// Only the dataset of the active dashboard is held — nothing is prefetched.
|
||||||
let currentView = "amazon-abgangsland";
|
let currentView = "amazon-abgangsland";
|
||||||
let lastData = null; // { abgangsland, vat }
|
let lastData = null; // { view, data } — data is the normalized dataset of `view`
|
||||||
|
let loadToken = 0; // guards against stale responses from superseded requests
|
||||||
|
|
||||||
/* ---------- Auth (HTTP Basic) ---------- */
|
/* ---------- Auth (HTTP Basic) ---------- */
|
||||||
|
|
||||||
@@ -121,13 +155,17 @@ function buildPeriodSelectors() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
els.month.value = String(defMonth);
|
els.month.value = String(defMonth);
|
||||||
|
els.quarter.value = String(Math.floor(defMonth / 3) + 1);
|
||||||
els.year.value = String(defYear);
|
els.year.value = String(defYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedPeriod() {
|
function selectedPeriod() {
|
||||||
|
const meta = VIEWS[currentView];
|
||||||
|
if (meta && meta.quarter) {
|
||||||
|
return `${els.year.value}-Q${els.quarter.value}`;
|
||||||
|
}
|
||||||
const month = MONTHS[Number(els.month.value)];
|
const month = MONTHS[Number(els.month.value)];
|
||||||
const year = els.year.value;
|
return `${els.year.value}-${month}`;
|
||||||
return `${year}-${month}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Formatting ---------- */
|
/* ---------- Formatting ---------- */
|
||||||
@@ -160,6 +198,9 @@ function formatQuantity(value) {
|
|||||||
/* ---------- Data ---------- */
|
/* ---------- Data ---------- */
|
||||||
|
|
||||||
function extractArray(payload, key) {
|
function extractArray(payload, key) {
|
||||||
|
// The webhook may return the requested dataset either directly as an array
|
||||||
|
// or wrapped under its query key.
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
if (!payload || typeof payload !== "object") return [];
|
if (!payload || typeof payload !== "object") return [];
|
||||||
if (Array.isArray(payload[key])) return payload[key];
|
if (Array.isArray(payload[key])) return payload[key];
|
||||||
const match = Object.keys(payload).find((k) => k.toLowerCase() === key.toLowerCase());
|
const match = Object.keys(payload).find((k) => k.toLowerCase() === key.toLowerCase());
|
||||||
@@ -217,6 +258,152 @@ function normalizeVat(rows) {
|
|||||||
return { countries, totals };
|
return { countries, totals };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Amounts may arrive either as plain numbers (SQL numeric) or as German
|
||||||
|
// formatted strings ("90.717,93 €") — accept both.
|
||||||
|
function parseAmount(value) {
|
||||||
|
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
||||||
|
return parseEuro(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRechnungenTotals(invoices) {
|
||||||
|
return invoices.reduce(
|
||||||
|
(acc, i) => {
|
||||||
|
acc.gesamtBrutto += i.gesamtBrutto;
|
||||||
|
acc.gesamtNetto += i.gesamtNetto;
|
||||||
|
acc.brutto += i.brutto;
|
||||||
|
acc.bruttoErm += i.bruttoErm;
|
||||||
|
acc.netto += i.netto;
|
||||||
|
acc.nettoErm += i.nettoErm;
|
||||||
|
acc.versandBrutto += i.versandBrutto;
|
||||||
|
acc.versandNetto += i.versandNetto;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ gesamtBrutto: 0, gesamtNetto: 0, brutto: 0, bruttoErm: 0, netto: 0, nettoErm: 0, versandBrutto: 0, versandNetto: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRechnungen(rows) {
|
||||||
|
const invoices = rows.map((row) => ({
|
||||||
|
datum: row["Rechnungsdatum"] ?? "",
|
||||||
|
nummer: row["Rechnungsnummer"] ?? "",
|
||||||
|
land: row["Rechnungsland"] ?? "",
|
||||||
|
landCode: row["Rechnungsland Ländercode"] ?? "",
|
||||||
|
zahlart: row["Zahlart"] ?? "",
|
||||||
|
zahlungsreferenz: row["Zahlungsreference der Bestellung"] ?? "",
|
||||||
|
vatId: row["USt. Id / VAT Id des Käufers (in Bestellung)"] ?? "",
|
||||||
|
gesamtBrutto: parseAmount(row["Gesamtpreis brutto"]),
|
||||||
|
gesamtNetto: parseAmount(row["Gesamtpreis netto"]),
|
||||||
|
brutto: parseAmount(row["Brutto"]),
|
||||||
|
bruttoErm: parseAmount(row["Brutto erm"]),
|
||||||
|
ustSatz: parseAmount(row["USt. Satz"]),
|
||||||
|
ustSatzErm: parseAmount(row["Ust. Satz erm"]),
|
||||||
|
netto: parseAmount(row["Netto Betrag"]),
|
||||||
|
nettoErm: parseAmount(row["Netto Betrag erm"]),
|
||||||
|
versandBrutto: parseAmount(row["Versandkosten brutto"]),
|
||||||
|
versandNetto: parseAmount(row["Versandkosten netto"]),
|
||||||
|
auftragsdatum: row["Auftragsdatum"] ?? "",
|
||||||
|
bestellnummer: row["Bestellnummer"] ?? "",
|
||||||
|
zahldatum: row["Zahldatum"] ?? "",
|
||||||
|
lieferName: row["Lieferadresse Name"] ?? "",
|
||||||
|
tag: row["Tag"] ?? "",
|
||||||
|
lieferland: row["Lieferland"] ?? "",
|
||||||
|
quelle: row["Quelle"] ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { invoices, totals: computeRechnungenTotals(invoices) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- ODOO Rechnungen pro Monat: filters ---------- */
|
||||||
|
|
||||||
|
let rechnungenFilter = { tag: "", bestellnummer: "", lieferland: "", nummer: "", quelle: "" };
|
||||||
|
|
||||||
|
function matchesFilter(value, query) {
|
||||||
|
if (!query) return true;
|
||||||
|
return String(value).toLowerCase().includes(query.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRechnungenFilters(data) {
|
||||||
|
const f = rechnungenFilter;
|
||||||
|
const invoices = data.invoices.filter((i) =>
|
||||||
|
matchesFilter(i.tag, f.tag) &&
|
||||||
|
matchesFilter(i.bestellnummer, f.bestellnummer) &&
|
||||||
|
matchesFilter(i.lieferland, f.lieferland) &&
|
||||||
|
matchesFilter(i.nummer, f.nummer) &&
|
||||||
|
matchesFilter(i.quelle, f.quelle)
|
||||||
|
);
|
||||||
|
return { invoices, totals: computeRechnungenTotals(invoices) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rechnungenFilterInputs() {
|
||||||
|
if (currentView === "gesamt-rechnungen-pivot") {
|
||||||
|
return [els.pivotFilterTag, els.pivotFilterBestellnummer, els.pivotFilterLieferland, els.pivotFilterNummer, els.pivotFilterQuelle];
|
||||||
|
}
|
||||||
|
if (currentView === "schweiz-quartal") {
|
||||||
|
return [els.chFilterTag, els.chFilterBestellnummer, els.chFilterLieferland, els.chFilterNummer, els.chFilterQuelle];
|
||||||
|
}
|
||||||
|
if (currentView === "odoo-rechnungen") {
|
||||||
|
return [els.odooFilterTag, els.odooFilterBestellnummer, els.odooFilterLieferland, els.odooFilterNummer];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every filter input across the invoice dashboards. Attached once at startup,
|
||||||
|
// because the views are only toggled — the inputs stay in the DOM.
|
||||||
|
function allRechnungenFilterInputs() {
|
||||||
|
return [
|
||||||
|
els.odooFilterTag, els.odooFilterBestellnummer, els.odooFilterLieferland, els.odooFilterNummer,
|
||||||
|
els.pivotFilterTag, els.pivotFilterBestellnummer, els.pivotFilterLieferland, els.pivotFilterNummer, els.pivotFilterQuelle,
|
||||||
|
els.chFilterTag, els.chFilterBestellnummer, els.chFilterLieferland, els.chFilterNummer, els.chFilterQuelle,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRechnungenFilters() {
|
||||||
|
rechnungenFilter = { tag: "", bestellnummer: "", lieferland: "", nummer: "", quelle: "" };
|
||||||
|
rechnungenFilterInputs().forEach((input) => { if (input) input.value = ""; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRechnungenFilter() {
|
||||||
|
const [tag, bestellnummer, lieferland, nummer, quelle] = rechnungenFilterInputs();
|
||||||
|
rechnungenFilter = {
|
||||||
|
tag: (tag?.value || "").trim(),
|
||||||
|
bestellnummer: (bestellnummer?.value || "").trim(),
|
||||||
|
lieferland: (lieferland?.value || "").trim(),
|
||||||
|
nummer: (nummer?.value || "").trim(),
|
||||||
|
quelle: (quelle?.value || "").trim(),
|
||||||
|
};
|
||||||
|
if (lastData && lastData.view === currentView) {
|
||||||
|
renderActiveView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRechnungenFilters() {
|
||||||
|
allRechnungenFilterInputs().forEach((input) => { if (input) input.addEventListener("input", onRechnungenFilter); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRechnungenPivot() {
|
||||||
|
wirePivotPreset(els.odooPivotPreset, "odoo-rechnungen");
|
||||||
|
wirePivotPreset(els.pivotPreset, "gesamt-rechnungen-pivot");
|
||||||
|
wirePivotPreset(els.chPreset, "schweiz-quartal");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wirePivotPreset(select, view) {
|
||||||
|
if (!select) return;
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
const presets = PIVOT_PRESETS[view];
|
||||||
|
const preset = presets[select.value] || presets.standard;
|
||||||
|
pivotOptions[view] = {
|
||||||
|
rows: [...preset.rows],
|
||||||
|
cols: [...preset.cols],
|
||||||
|
vals: [...preset.vals],
|
||||||
|
aggregatorName: preset.aggregatorName,
|
||||||
|
rendererName: preset.rendererName,
|
||||||
|
};
|
||||||
|
// A preset also loads the default (empty) filters.
|
||||||
|
resetRechnungenFilters();
|
||||||
|
renderActiveView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Rendering: Umsatz je Abgangsland ---------- */
|
/* ---------- Rendering: Umsatz je Abgangsland ---------- */
|
||||||
|
|
||||||
function colorFor(bereich, map) {
|
function colorFor(bereich, map) {
|
||||||
@@ -428,6 +615,171 @@ function renderVat(data) {
|
|||||||
renderVatTable(data);
|
renderVatTable(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Rendering: ODOO Rechnungen pro Monat ---------- */
|
||||||
|
|
||||||
|
function renderRechnungenKpis(data) {
|
||||||
|
const v = viewEls();
|
||||||
|
if (!v) return;
|
||||||
|
const { invoices, totals } = data;
|
||||||
|
const ust = totals.gesamtBrutto - totals.gesamtNetto;
|
||||||
|
|
||||||
|
v.kpis.innerHTML = `
|
||||||
|
<div class="kpi primary">
|
||||||
|
<div class="kpi-label">Gesamt brutto</div>
|
||||||
|
<div class="kpi-value">${formatEuro(totals.gesamtBrutto)}</div>
|
||||||
|
<div class="kpi-sub">alle Rechnungen im Zeitraum</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">Rechnungen</div>
|
||||||
|
<div class="kpi-value">${invoices.length}</div>
|
||||||
|
<div class="kpi-sub">Datensätze</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">Gesamt netto</div>
|
||||||
|
<div class="kpi-value">${formatEuro(totals.gesamtNetto)}</div>
|
||||||
|
<div class="kpi-sub">ohne USt</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">USt (brutto − netto)</div>
|
||||||
|
<div class="kpi-value">${formatEuro(ust)}</div>
|
||||||
|
<div class="kpi-sub">enthaltene Umsatzsteuer</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRechnungenChart(data) {
|
||||||
|
const v = viewEls();
|
||||||
|
if (!v || !v.chart) return;
|
||||||
|
|
||||||
|
const byCountry = new Map();
|
||||||
|
data.invoices.forEach((inv) => {
|
||||||
|
const key = inv.landCode || inv.land || "–";
|
||||||
|
byCountry.set(key, (byCountry.get(key) || 0) + inv.gesamtBrutto);
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = [...byCountry.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const grand = entries.reduce((sum, [, value]) => sum + value, 0);
|
||||||
|
const max = entries.reduce((m, [, value]) => Math.max(m, value), 0) || 1;
|
||||||
|
|
||||||
|
v.chart.innerHTML = "";
|
||||||
|
v.legend.innerHTML = "";
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
v.chart.innerHTML = '<p class="muted">Keine Daten für diesen Zeitraum.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach(([code, value]) => {
|
||||||
|
const pct = (value / max) * 100;
|
||||||
|
const share = grand ? (value / grand) * 100 : 0;
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "bar-row";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="bar-country">${code}</div>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div class="bar-fill" style="background:var(--accent)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bar-value">${formatEuro(value)} · ${formatPercent(share)}</div>`;
|
||||||
|
v.chart.appendChild(row);
|
||||||
|
|
||||||
|
const fill = row.querySelector(".bar-fill");
|
||||||
|
fill.getBoundingClientRect();
|
||||||
|
fill.style.width = Math.max(pct, 6) + "%";
|
||||||
|
fill.classList.add("ready");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Rechnungen pivot (experiment) ---------- */
|
||||||
|
|
||||||
|
// Preset pivot layouts per view, selectable from each view's "Vorlage" dropdown.
|
||||||
|
const PIVOT_PRESETS = {
|
||||||
|
"gesamt-rechnungen-pivot": {
|
||||||
|
standard: { rows: ["Quelle"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
|
||||||
|
rechnungsland: { rows: ["Quelle"], cols: ["Rechnungsland"], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
|
||||||
|
},
|
||||||
|
"odoo-rechnungen": {
|
||||||
|
standard: { rows: ["Rechnungsland"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
|
||||||
|
zahlart: { rows: ["Zahlart"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
|
||||||
|
},
|
||||||
|
"schweiz-quartal": {
|
||||||
|
standard: { rows: ["Quelle"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
|
||||||
|
rechnungsland: { rows: ["Quelle"], cols: ["Rechnungsland"], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Current pivot layout per view (kept so a filter re-render doesn't reset it).
|
||||||
|
const pivotOptions = {
|
||||||
|
"gesamt-rechnungen-pivot": { ...PIVOT_PRESETS["gesamt-rechnungen-pivot"].standard },
|
||||||
|
"odoo-rechnungen": { ...PIVOT_PRESETS["odoo-rechnungen"].standard },
|
||||||
|
"schweiz-quartal": { ...PIVOT_PRESETS["schweiz-quartal"].standard },
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderRechnungenPivot(data) {
|
||||||
|
const v = viewEls();
|
||||||
|
if (!v || !v.pivot) return;
|
||||||
|
|
||||||
|
renderRechnungenKpis(data);
|
||||||
|
if (v.chart) renderRechnungenChart(data);
|
||||||
|
|
||||||
|
// Recreate the container so pivotUI cannot reuse state from a previous
|
||||||
|
// render — clearing innerHTML alone is not enough, so a preset change would
|
||||||
|
// not take effect.
|
||||||
|
const prev = v.pivot;
|
||||||
|
const el = prev.cloneNode(false);
|
||||||
|
prev.parentNode.replaceChild(el, prev);
|
||||||
|
|
||||||
|
if (!window.jQuery || !jQuery.fn.pivotUI) {
|
||||||
|
el.innerHTML = '<p class="muted">PivotTable.js ist nicht geladen (offline?).</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.invoices.length) {
|
||||||
|
el.innerHTML = '<p class="muted">Keine Daten für diesen Zeitraum.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flaches Datenmodell für die Pivot-Tabelle. Alle Attribute sind im
|
||||||
|
// pivotUI-Bedienfeld frei kombinierbar (Zeilen/Spalten/Werte/Aggregator).
|
||||||
|
// Hinweis: "Brutto" ist nur der Normalsatz-Anteil; "Gesamtpreis brutto" ist
|
||||||
|
// Brutto + Brutto erm (= die Summe, die die KPI "Gesamt brutto" anzeigt).
|
||||||
|
const rows = data.invoices.map((i) => ({
|
||||||
|
Quelle: i.quelle || "(leer)",
|
||||||
|
Rechnungsland: i.land || "(leer)",
|
||||||
|
Zahlart: i.zahlart || "(leer)",
|
||||||
|
Bestellnummer: i.bestellnummer || "(leer)",
|
||||||
|
Tag: i.tag || "(leer)",
|
||||||
|
Lieferland: i.lieferland || "(leer)",
|
||||||
|
"Gesamtpreis brutto": i.gesamtBrutto,
|
||||||
|
Brutto: i.brutto,
|
||||||
|
"Brutto erm": i.bruttoErm,
|
||||||
|
"Gesamtpreis netto": i.gesamtNetto,
|
||||||
|
Netto: i.netto,
|
||||||
|
"Netto erm": i.nettoErm,
|
||||||
|
"Versandkosten brutto": i.versandBrutto,
|
||||||
|
"Versandkosten netto": i.versandNetto,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
...pivotOptions[currentView],
|
||||||
|
unusedAttrsVertical: true,
|
||||||
|
onRefresh: (config) => {
|
||||||
|
pivotOptions[currentView] = {
|
||||||
|
rows: config.rows,
|
||||||
|
cols: config.cols,
|
||||||
|
vals: config.vals,
|
||||||
|
aggregatorName: config.aggregatorName,
|
||||||
|
rendererName: config.rendererName,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Only offer the plain table renderer (no heatmaps/charts).
|
||||||
|
const tableRenderer = jQuery.pivotUtilities && jQuery.pivotUtilities.renderers
|
||||||
|
? jQuery.pivotUtilities.renderers["Table"]
|
||||||
|
: null;
|
||||||
|
if (tableRenderer) opts.renderers = { Table: tableRenderer };
|
||||||
|
|
||||||
|
jQuery(el).pivotUI(rows, opts);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Active view helpers ---------- */
|
/* ---------- Active view helpers ---------- */
|
||||||
|
|
||||||
function viewEls() {
|
function viewEls() {
|
||||||
@@ -437,15 +789,26 @@ function viewEls() {
|
|||||||
if (currentView === "amazon-vat-summary") {
|
if (currentView === "amazon-vat-summary") {
|
||||||
return { kpis: els.vatKpis, chart: els.vatChart, legend: els.vatLegend, table: els.vatTable, state: els.vatState };
|
return { kpis: els.vatKpis, chart: els.vatChart, legend: els.vatLegend, table: els.vatTable, state: els.vatState };
|
||||||
}
|
}
|
||||||
|
if (currentView === "odoo-rechnungen") {
|
||||||
|
return { kpis: els.odooKpis, chart: els.odooChart, legend: els.odooLegend, state: els.odooState, pivot: document.getElementById("odoo-pivot") };
|
||||||
|
}
|
||||||
|
if (currentView === "gesamt-rechnungen-pivot") {
|
||||||
|
return { kpis: els.pivotKpis, state: els.pivotState, pivot: document.getElementById("pivot") };
|
||||||
|
}
|
||||||
|
if (currentView === "schweiz-quartal") {
|
||||||
|
return { kpis: els.chKpis, state: els.chState, pivot: document.getElementById("ch-pivot") };
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderActiveView() {
|
function renderActiveView() {
|
||||||
if (!lastData) return;
|
if (!lastData || lastData.view !== currentView) return;
|
||||||
if (currentView === "amazon-abgangsland") {
|
if (currentView === "amazon-abgangsland") {
|
||||||
renderAbgangsland(lastData.abgangsland);
|
renderAbgangsland(lastData.data);
|
||||||
} else if (currentView === "amazon-vat-summary") {
|
} else if (currentView === "amazon-vat-summary") {
|
||||||
renderVat(lastData.vat);
|
renderVat(lastData.data);
|
||||||
|
} else if (currentView === "odoo-rechnungen" || currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal") {
|
||||||
|
renderRechnungenPivot(applyRechnungenFilters(lastData.data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,9 +829,10 @@ function showSkeleton() {
|
|||||||
if (!v) return;
|
if (!v) return;
|
||||||
v.state.classList.add("hidden");
|
v.state.classList.add("hidden");
|
||||||
v.kpis.innerHTML = skeletonKpis();
|
v.kpis.innerHTML = skeletonKpis();
|
||||||
v.chart.innerHTML = skeletonBars();
|
if (v.chart) v.chart.innerHTML = skeletonBars();
|
||||||
v.legend.innerHTML = "";
|
if (v.legend) v.legend.innerHTML = "";
|
||||||
v.table.innerHTML = "";
|
if (v.table) v.table.innerHTML = "";
|
||||||
|
if (v.pivot) v.pivot.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
@@ -482,24 +846,52 @@ function clearViews() {
|
|||||||
const v = viewEls();
|
const v = viewEls();
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
v.kpis.innerHTML = "";
|
v.kpis.innerHTML = "";
|
||||||
v.chart.innerHTML = "";
|
if (v.chart) v.chart.innerHTML = "";
|
||||||
v.legend.innerHTML = "";
|
if (v.legend) v.legend.innerHTML = "";
|
||||||
v.table.innerHTML = "";
|
if (v.table) v.table.innerHTML = "";
|
||||||
|
if (v.pivot) v.pivot.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const meta = VIEWS[currentView];
|
||||||
|
const query = meta && meta.query;
|
||||||
|
|
||||||
|
// The datasets are huge: only the dataset of the displayed dashboard is
|
||||||
|
// requested, and only when that dashboard is shown or reloaded.
|
||||||
|
if (!query) {
|
||||||
|
lastData = null;
|
||||||
|
clearViews();
|
||||||
|
updateDownloadState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetRechnungenFilters();
|
||||||
|
|
||||||
const period = selectedPeriod();
|
const period = selectedPeriod();
|
||||||
els.tablePeriod.textContent = period;
|
els.tablePeriod.textContent = period;
|
||||||
els.vatPeriod.textContent = period;
|
els.vatPeriod.textContent = period;
|
||||||
|
els.odooPeriod.textContent = period;
|
||||||
|
els.pivotPeriod.textContent = period;
|
||||||
|
els.chPeriod.textContent = period;
|
||||||
els.refresh.classList.add("spinning");
|
els.refresh.classList.add("spinning");
|
||||||
showSkeleton();
|
showSkeleton();
|
||||||
|
|
||||||
|
const token = ++loadToken;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers = { Accept: "application/json" };
|
const headers = { Accept: "application/json", "Content-Type": "application/json" };
|
||||||
const authorization = authHeader();
|
const authorization = authHeader();
|
||||||
if (authorization) headers.Authorization = authorization;
|
if (authorization) headers.Authorization = authorization;
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}?month=${encodeURIComponent(period)}`, { headers });
|
const body = meta.quarter ? { query, quarter: period } : { query, month: period };
|
||||||
|
const response = await fetch(API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
// A newer request (view/period switch) superseded this one — ignore it.
|
||||||
|
if (token !== loadToken) return;
|
||||||
|
|
||||||
// The webhook is protected with Basic Auth: ask for credentials when needed.
|
// The webhook is protected with Basic Auth: ask for credentials when needed.
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -515,10 +907,7 @@ async function load() {
|
|||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
lastData = {
|
lastData = { view: currentView, data: meta.normalize(extractArray(payload, query)) };
|
||||||
abgangsland: normalizeAbgangsland(extractArray(payload, "Summe je Abgangsland")),
|
|
||||||
vat: normalizeVat(extractArray(payload, "VAT Summary")),
|
|
||||||
};
|
|
||||||
|
|
||||||
renderActiveView();
|
renderActiveView();
|
||||||
|
|
||||||
@@ -528,6 +917,7 @@ async function load() {
|
|||||||
v.state.textContent = "";
|
v.state.textContent = "";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (token !== loadToken) return;
|
||||||
lastData = null;
|
lastData = null;
|
||||||
clearViews();
|
clearViews();
|
||||||
showError(`Daten konnten nicht geladen werden: ${error.message}`);
|
showError(`Daten konnten nicht geladen werden: ${error.message}`);
|
||||||
@@ -578,19 +968,63 @@ function buildVatCsv(data) {
|
|||||||
return lines.join("\r\n");
|
return lines.join("\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRechnungenCsv(data) {
|
||||||
|
const showQuelle = currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal";
|
||||||
|
const header = [
|
||||||
|
"Rechnungsdatum", "Rechnungsnummer", "Rechnungsland", "Rechnungsland Ländercode",
|
||||||
|
"Zahlart", "Zahlungsreference der Bestellung",
|
||||||
|
"USt. Id / VAT Id des Käufers (in Bestellung)", "Gesamtpreis brutto", "Gesamtpreis netto",
|
||||||
|
"Brutto", "Brutto erm", "USt. Satz", "Ust. Satz erm", "Netto Betrag", "Netto Betrag erm",
|
||||||
|
"Versandkosten brutto", "Versandkosten netto", "Auftragsdatum", "Bestellnummer", "Zahldatum",
|
||||||
|
"Lieferadresse Name", "Tag", "Lieferland",
|
||||||
|
];
|
||||||
|
if (showQuelle) header.push("Quelle");
|
||||||
|
const lines = [header.map(csvEscape).join(";")];
|
||||||
|
|
||||||
|
data.invoices.forEach((i) => {
|
||||||
|
const cols = [
|
||||||
|
i.datum, i.nummer, i.land, i.landCode, i.zahlart, i.zahlungsreferenz, i.vatId,
|
||||||
|
formatEuro(i.gesamtBrutto), formatEuro(i.gesamtNetto),
|
||||||
|
formatEuro(i.brutto), formatEuro(i.bruttoErm), formatQuantity(i.ustSatz), formatQuantity(i.ustSatzErm),
|
||||||
|
formatEuro(i.netto), formatEuro(i.nettoErm),
|
||||||
|
formatEuro(i.versandBrutto), formatEuro(i.versandNetto),
|
||||||
|
i.auftragsdatum, i.bestellnummer, i.zahldatum, i.lieferName, i.tag, i.lieferland,
|
||||||
|
];
|
||||||
|
if (showQuelle) cols.push(i.quelle);
|
||||||
|
lines.push(cols.map(csvEscape).join(";"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const t = data.totals;
|
||||||
|
const totalCols = [
|
||||||
|
"Gesamt", String(data.invoices.length), "", "", "", "", "",
|
||||||
|
formatEuro(t.gesamtBrutto), formatEuro(t.gesamtNetto),
|
||||||
|
formatEuro(t.brutto), formatEuro(t.bruttoErm), "", "",
|
||||||
|
formatEuro(t.netto), formatEuro(t.nettoErm),
|
||||||
|
formatEuro(t.versandBrutto), formatEuro(t.versandNetto),
|
||||||
|
"", "", "", "", "", "",
|
||||||
|
];
|
||||||
|
if (showQuelle) totalCols.push("");
|
||||||
|
lines.push(totalCols.map(csvEscape).join(";"));
|
||||||
|
|
||||||
|
return lines.join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
function downloadCsv() {
|
function downloadCsv() {
|
||||||
if (!lastData) return;
|
if (!lastData || lastData.view !== currentView) return;
|
||||||
|
|
||||||
const period = selectedPeriod();
|
const period = selectedPeriod();
|
||||||
let csv;
|
let csv;
|
||||||
let filename;
|
let filename;
|
||||||
|
|
||||||
if (currentView === "amazon-abgangsland") {
|
if (currentView === "amazon-abgangsland") {
|
||||||
csv = buildAbgangslandCsv(lastData.abgangsland);
|
csv = buildAbgangslandCsv(lastData.data);
|
||||||
filename = `umsatz-je-abgangsland_${period}.csv`;
|
filename = `umsatz-je-abgangsland_${period}.csv`;
|
||||||
} else if (currentView === "amazon-vat-summary") {
|
} else if (currentView === "amazon-vat-summary") {
|
||||||
csv = buildVatCsv(lastData.vat);
|
csv = buildVatCsv(lastData.data);
|
||||||
filename = `vat-summary_${period}.csv`;
|
filename = `vat-summary_${period}.csv`;
|
||||||
|
} else if (currentView === "odoo-rechnungen" || currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal") {
|
||||||
|
csv = buildRechnungenCsv(applyRechnungenFilters(lastData.data));
|
||||||
|
filename = `${currentView}_${period}.csv`;
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -609,8 +1043,13 @@ function downloadCsv() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateDownloadState() {
|
function updateDownloadState() {
|
||||||
const supported = currentView === "amazon-abgangsland" || currentView === "amazon-vat-summary";
|
const supported =
|
||||||
els.download.disabled = !(lastData && supported);
|
currentView === "amazon-abgangsland" ||
|
||||||
|
currentView === "amazon-vat-summary" ||
|
||||||
|
currentView === "odoo-rechnungen" ||
|
||||||
|
currentView === "gesamt-rechnungen-pivot" ||
|
||||||
|
currentView === "schweiz-quartal";
|
||||||
|
els.download.disabled = !(lastData && lastData.view === currentView && supported);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Navigation ---------- */
|
/* ---------- Navigation ---------- */
|
||||||
@@ -619,14 +1058,34 @@ const VIEWS = {
|
|||||||
"amazon-abgangsland": {
|
"amazon-abgangsland": {
|
||||||
title: "Umsatz je Abgangsland",
|
title: "Umsatz je Abgangsland",
|
||||||
subtitle: "Amazon · Brutto (inkl. USt)",
|
subtitle: "Amazon · Brutto (inkl. USt)",
|
||||||
|
// Webhook query key and row normalizer for this dashboard's dataset.
|
||||||
|
query: "Summe je Abgangsland",
|
||||||
|
normalize: normalizeAbgangsland,
|
||||||
},
|
},
|
||||||
"amazon-vat-summary": {
|
"amazon-vat-summary": {
|
||||||
title: "VAT Summary",
|
title: "VAT Summary",
|
||||||
subtitle: "Amazon · USt-Übersicht je Land",
|
subtitle: "Amazon · USt-Übersicht je Land",
|
||||||
|
query: "VAT Summary",
|
||||||
|
normalize: normalizeVat,
|
||||||
},
|
},
|
||||||
"odoo-dashboard": {
|
"odoo-rechnungen": {
|
||||||
title: "ODOO Dashboard",
|
title: "ODOO Rechnungen pro Monat",
|
||||||
subtitle: "ODOO",
|
subtitle: "ODOO · Rechnungen je Monat",
|
||||||
|
query: "ODOO Rechnungen pro Monat",
|
||||||
|
normalize: normalizeRechnungen,
|
||||||
|
},
|
||||||
|
"gesamt-rechnungen-pivot": {
|
||||||
|
title: "Alle Rechnungen pro Monat",
|
||||||
|
subtitle: "Gesamt · Pivot",
|
||||||
|
query: "Alle Rechnungen pro Monat",
|
||||||
|
normalize: normalizeRechnungen,
|
||||||
|
},
|
||||||
|
"schweiz-quartal": {
|
||||||
|
title: "Quartal Schweiz",
|
||||||
|
subtitle: "Schweiz · AMAZON + ODOO",
|
||||||
|
query: "Quartal Schweiz",
|
||||||
|
normalize: normalizeRechnungen,
|
||||||
|
quarter: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -664,7 +1123,12 @@ function showView(name) {
|
|||||||
document.getElementById("view-subtitle").textContent = meta.subtitle;
|
document.getElementById("view-subtitle").textContent = meta.subtitle;
|
||||||
|
|
||||||
currentView = name;
|
currentView = name;
|
||||||
renderActiveView();
|
// Quarter views use the quarter selector instead of the month selector.
|
||||||
|
const isQuarter = !!meta.quarter;
|
||||||
|
els.month.classList.toggle("hidden", isQuarter);
|
||||||
|
els.quarter.classList.toggle("hidden", !isQuarter);
|
||||||
|
// Query only this dashboard's dataset — nothing is prefetched.
|
||||||
|
load();
|
||||||
updateDownloadState();
|
updateDownloadState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +1156,11 @@ function setupAuth() {
|
|||||||
buildPeriodSelectors();
|
buildPeriodSelectors();
|
||||||
setupMenu();
|
setupMenu();
|
||||||
setupAuth();
|
setupAuth();
|
||||||
|
setupRechnungenFilters();
|
||||||
|
setupRechnungenPivot();
|
||||||
|
load();
|
||||||
els.month.addEventListener("change", load);
|
els.month.addEventListener("change", load);
|
||||||
|
els.quarter.addEventListener("change", load);
|
||||||
els.year.addEventListener("change", load);
|
els.year.addEventListener("change", load);
|
||||||
els.refresh.addEventListener("click", load);
|
els.refresh.addEventListener("click", load);
|
||||||
els.download.addEventListener("click", downloadCsv);
|
els.download.addEventListener("click", downloadCsv);
|
||||||
|
|||||||
+199
-13
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Brandize · BI Dashboard</title>
|
<title>Brandize · BI Dashboard</title>
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<!-- PivotTable.js experiment (needs jQuery UI styles) -->
|
||||||
|
<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/base/jquery-ui.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pivottable@2.23.0/dist/pivot.min.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
@@ -65,8 +68,54 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="submenu">
|
<div class="submenu">
|
||||||
<a class="submenu-item" href="#" data-view="odoo-dashboard">
|
<a class="submenu-item" href="#" data-view="odoo-rechnungen">
|
||||||
Dashboard
|
ODOO Rechnungen pro Monat
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gesamt -->
|
||||||
|
<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="#4f46e5" />
|
||||||
|
<text x="24" y="32" text-anchor="middle" font-size="24" font-weight="700"
|
||||||
|
fill="#ffffff" font-family="Arial, sans-serif">Σ</text>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="menu-name">Gesamt</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="gesamt-rechnungen-pivot">
|
||||||
|
Alle Rechnungen pro Monat
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schweiz -->
|
||||||
|
<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="#d52b1e" />
|
||||||
|
<text x="24" y="30" text-anchor="middle" font-size="15" font-weight="700"
|
||||||
|
fill="#ffffff" font-family="Arial, sans-serif">CH</text>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="menu-name">Schweiz</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="schweiz-quartal">
|
||||||
|
Quartal Schweiz
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +157,12 @@
|
|||||||
<span class="field-label">Zeitraum</span>
|
<span class="field-label">Zeitraum</span>
|
||||||
<div class="period">
|
<div class="period">
|
||||||
<select id="month-select" aria-label="Monat"></select>
|
<select id="month-select" aria-label="Monat"></select>
|
||||||
|
<select id="quarter-select" aria-label="Quartal" class="hidden">
|
||||||
|
<option value="1">Q1</option>
|
||||||
|
<option value="2">Q2</option>
|
||||||
|
<option value="3">Q3</option>
|
||||||
|
<option value="4">Q4</option>
|
||||||
|
</select>
|
||||||
<select id="year-select" aria-label="Jahr"></select>
|
<select id="year-select" aria-label="Jahr"></select>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -192,19 +247,146 @@
|
|||||||
<div class="state" id="vat-state"></div>
|
<div class="state" id="vat-state"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- View: ODOO · Dashboard (placeholder) -->
|
<!-- View: ODOO · Rechnungen pro Monat -->
|
||||||
<main id="view-odoo-dashboard" class="view hidden">
|
<main id="view-odoo-rechnungen" class="view hidden">
|
||||||
<section class="panel placeholder">
|
<section class="kpis" id="odoo-kpis"></section>
|
||||||
<div class="placeholder-icon">
|
|
||||||
<svg viewBox="0 0 48 48" aria-hidden="true">
|
<section class="panel">
|
||||||
<rect width="48" height="48" rx="12" fill="#714B67" />
|
<div class="panel-head">
|
||||||
<text x="24" y="30" text-anchor="middle" font-size="14" font-weight="700"
|
<h2>Gesamtpreis brutto je Rechnungsland</h2>
|
||||||
fill="#ffffff" font-family="Arial, sans-serif">odoo</text>
|
<div class="legend" id="odoo-legend"></div>
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<h2>ODOO Dashboard</h2>
|
<div class="chart" id="odoo-chart"></div>
|
||||||
<p class="muted">Dieses Dashboard folgt in Kürze.</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Pivot — Summe Gesamtpreis brutto</h2>
|
||||||
|
<div class="panel-head-actions">
|
||||||
|
<span class="muted" id="odoo-period"></span>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Vorlage</span>
|
||||||
|
<select id="odoo-pivot-preset" aria-label="Pivot-Vorlage">
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="zahlart">Zahlart</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-filters">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Tag</span>
|
||||||
|
<input id="odoo-filter-tag" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Bestellnummer</span>
|
||||||
|
<input id="odoo-filter-bestellnummer" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Lieferland</span>
|
||||||
|
<input id="odoo-filter-lieferland" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Rechnungsnummer</span>
|
||||||
|
<input id="odoo-filter-rechnungsnummer" type="text" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="odoo-pivot"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="state" id="odoo-state"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- View: Gesamt · Alle Rechnungen pro Monat -->
|
||||||
|
<main id="view-gesamt-rechnungen-pivot" class="view hidden">
|
||||||
|
<section class="kpis" id="pivot-kpis"></section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Pivot — Summe Gesamtpreis brutto</h2>
|
||||||
|
<div class="panel-head-actions">
|
||||||
|
<span class="muted" id="pivot-period"></span>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Vorlage</span>
|
||||||
|
<select id="pivot-preset" aria-label="Pivot-Vorlage">
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="rechnungsland">Rechnungsland (Spalten)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-filters">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Quelle</span>
|
||||||
|
<input id="pivot-filter-quelle" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Tag</span>
|
||||||
|
<input id="pivot-filter-tag" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Bestellnummer</span>
|
||||||
|
<input id="pivot-filter-bestellnummer" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Lieferland</span>
|
||||||
|
<input id="pivot-filter-lieferland" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Rechnungsnummer</span>
|
||||||
|
<input id="pivot-filter-rechnungsnummer" type="text" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="pivot"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="state" id="pivot-state"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- View: Schweiz · Quartal Schweiz (AMAZON + ODOO, Lieferung in die CH) -->
|
||||||
|
<main id="view-schweiz-quartal" class="view hidden">
|
||||||
|
<section class="kpis" id="ch-kpis"></section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Pivot — Quartal Schweiz (Summe Gesamtpreis brutto)</h2>
|
||||||
|
<div class="panel-head-actions">
|
||||||
|
<span class="muted" id="ch-period"></span>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Vorlage</span>
|
||||||
|
<select id="ch-pivot-preset" aria-label="Pivot-Vorlage">
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="rechnungsland">Rechnungsland (Spalten)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-filters">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Quelle</span>
|
||||||
|
<input id="ch-filter-quelle" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Tag</span>
|
||||||
|
<input id="ch-filter-tag" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Bestellnummer</span>
|
||||||
|
<input id="ch-filter-bestellnummer" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Lieferland</span>
|
||||||
|
<input id="ch-filter-lieferland" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Rechnungsnummer</span>
|
||||||
|
<input id="ch-filter-rechnungsnummer" type="text" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="ch-pivot"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="state" id="ch-state"></div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,6 +423,10 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PivotTable.js experiment — requires jQuery + jQuery UI -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/pivottable@2.23.0/dist/pivot.min.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Alle Rechnungen pro Monat – Gesamt (ODOO + AMAZON)
|
||||||
|
-- Odoo 17 / PostgreSQL
|
||||||
|
--
|
||||||
|
-- Zweck:
|
||||||
|
-- Liefert dieselben Spalten wie Rechnungsexport.sql (ODOO-Kundenrechnungen),
|
||||||
|
-- aber zusammengefuehrt (UNION ALL) mit den Datensaetzen aus der Amazon-
|
||||||
|
-- Tabelle brandize_datacenter_amazon_vat_transaction. Zusaetzlich gibt die
|
||||||
|
-- Spalte "Quelle" an, aus welchem System die Zeile stammt ('ODOO'/'AMAZON').
|
||||||
|
--
|
||||||
|
-- Modelle:
|
||||||
|
-- ODOO : account.move (Kundenrechnungen / Gutschriften)
|
||||||
|
-- AMAZON : brandize_datacenter_amazon_vat_transaction
|
||||||
|
--
|
||||||
|
-- Parameter:
|
||||||
|
-- %(month)s Abrechnungszeitraum als Text, z. B. '2026-SEP'
|
||||||
|
-- ODOO : Rechnungsdatum im Kalendermonat (siehe params-CTE)
|
||||||
|
-- AMAZON: activity_period = %(month)s
|
||||||
|
--
|
||||||
|
-- Aufruf (Odoo / psycopg2):
|
||||||
|
-- cr.execute(open('Alle_Rechnungen.sql').read(), {'month': '2026-SEP'})
|
||||||
|
--
|
||||||
|
-- In psql stattdessen:
|
||||||
|
-- \set month '2026-SEP'
|
||||||
|
--
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Spalten-Mapping ODOO -> AMAZON
|
||||||
|
-- (Felder ohne Amazon-Pendant bleiben leer)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Rechnungsdatum <- transaction_complete_date (Fallback: depart)
|
||||||
|
-- Rechnungsnummer <- vat_inv_number
|
||||||
|
-- Rechnungsland <- Name zu sale_arrival_country (res_country)
|
||||||
|
-- Rechnungsland Code <- sale_arrival_country
|
||||||
|
-- Zahlart <- '' (kein Amazon-Feld)
|
||||||
|
-- Zahlungsreference <- transaction_event_id
|
||||||
|
-- USt. Id Kaeufer <- buyer_vat_number
|
||||||
|
-- Gesamtpreis brutto <- total_activity_value_amt_vat_incl
|
||||||
|
-- Gesamtpreis netto <- total_activity_value_amt_vat_excl
|
||||||
|
-- Brutto / -erm <- Steuersplit nach effektivem Satz (Schwelle
|
||||||
|
-- USt. Satz / -erm CH >= 8, sonst >= 16 - wie im ODOO-Export)
|
||||||
|
-- Netto Betrag / -erm Satz = total_activity_value_vat_amt / netto * 100
|
||||||
|
-- Versandkosten brutto <- total_ship_charge_amt_vat_incl
|
||||||
|
-- Versandkosten netto <- total_ship_charge_amt_vat_excl
|
||||||
|
-- Auftragsdatum <- transaction_depart_date
|
||||||
|
-- Bestellnummer <- activity_transaction_id
|
||||||
|
-- Zahldatum <- '' (kein Amazon-Feld)
|
||||||
|
-- Lieferadresse Name <- buyer_name
|
||||||
|
-- Tag <- '' (kein Amazon-Feld)
|
||||||
|
-- Lieferland <- Name zu arrival_country (res_country)
|
||||||
|
--
|
||||||
|
-- ACHTUNG: Das Amazon-Mapping ist eine 1:1-Uebertragung auf die ODOO-Spalten.
|
||||||
|
-- Die Datumszuordnung (Rechnungsdatum/Auftragsdatum) sowie Bestellnummer
|
||||||
|
-- sollten gegen die tatsaechliche Amazon-Report-Semantik geprueft und hier
|
||||||
|
-- ggf. angepasst werden.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
WITH params AS (
|
||||||
|
-- Kalendermonat aus %(month)s (z. B. '2026-SEP') ableiten (ODDO-Seite).
|
||||||
|
SELECT
|
||||||
|
to_date(
|
||||||
|
split_part(%(month)s, '-', 1) || '-' ||
|
||||||
|
CASE upper(split_part(%(month)s, '-', 2))
|
||||||
|
WHEN 'JAN' THEN '01' WHEN 'FEB' THEN '02'
|
||||||
|
WHEN 'MÄR' THEN '03' WHEN 'MAR' THEN '03' WHEN 'APR' THEN '04'
|
||||||
|
WHEN 'MAI' THEN '05' WHEN 'MAY' THEN '05' WHEN 'JUN' THEN '06'
|
||||||
|
WHEN 'JUL' THEN '07' WHEN 'AUG' THEN '08' WHEN 'SEP' THEN '09'
|
||||||
|
WHEN 'OKT' THEN '10' WHEN 'OCT' THEN '10' WHEN 'NOV' THEN '11'
|
||||||
|
WHEN 'DEZ' THEN '12' WHEN 'DEC' THEN '12'
|
||||||
|
END,
|
||||||
|
'YYYY-MM'
|
||||||
|
) AS first_day
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- ODOO-Seite (identisch zu Rechnungsexport.sql)
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
invoices AS (
|
||||||
|
SELECT
|
||||||
|
am.id,
|
||||||
|
am.name,
|
||||||
|
am.ref,
|
||||||
|
am.move_type,
|
||||||
|
am.invoice_date,
|
||||||
|
am.invoice_origin,
|
||||||
|
am.partner_id,
|
||||||
|
am.partner_shipping_id,
|
||||||
|
am.invoice_payment_term_id,
|
||||||
|
am.payment_reference
|
||||||
|
FROM account_move am
|
||||||
|
CROSS JOIN params p
|
||||||
|
WHERE am.invoice_date >= p.first_day
|
||||||
|
AND am.invoice_date < p.first_day + INTERVAL '1 month'
|
||||||
|
AND am.move_type IN ('out_invoice', 'out_refund')
|
||||||
|
),
|
||||||
|
|
||||||
|
line_taxes AS (
|
||||||
|
SELECT
|
||||||
|
aml.move_id AS invoice_id,
|
||||||
|
at.amount AS tax_rate,
|
||||||
|
SUM(aml.price_subtotal) AS netto,
|
||||||
|
SUM(aml.price_total) AS brutto
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN account_move_line aml
|
||||||
|
ON aml.move_id = inv.id
|
||||||
|
JOIN account_move_line_account_tax_rel amlatr
|
||||||
|
ON amlatr.account_move_line_id = aml.id
|
||||||
|
JOIN account_tax at
|
||||||
|
ON at.id = amlatr.account_tax_id
|
||||||
|
GROUP BY aml.move_id, at.amount
|
||||||
|
),
|
||||||
|
|
||||||
|
tax_split AS (
|
||||||
|
SELECT
|
||||||
|
lt.invoice_id,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
THEN ROUND(lt.brutto::numeric, 2)
|
||||||
|
END) AS brutto_normal,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
THEN ROUND(lt.tax_rate::numeric, 2)
|
||||||
|
END) AS ust_satz_normal,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
THEN ROUND(lt.netto::numeric, 2)
|
||||||
|
END) AS netto_normal,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(lt.brutto::numeric, 2)
|
||||||
|
END) AS brutto_erm,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(lt.tax_rate::numeric, 2)
|
||||||
|
END) AS ust_satz_erm,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(lt.netto::numeric, 2)
|
||||||
|
END) AS netto_erm
|
||||||
|
|
||||||
|
FROM line_taxes lt
|
||||||
|
JOIN invoices inv
|
||||||
|
ON inv.id = lt.invoice_id
|
||||||
|
JOIN res_partner rp
|
||||||
|
ON rp.id = inv.partner_id
|
||||||
|
LEFT JOIN res_country rc
|
||||||
|
ON rc.id = rp.country_id
|
||||||
|
GROUP BY lt.invoice_id
|
||||||
|
),
|
||||||
|
|
||||||
|
shipping AS (
|
||||||
|
SELECT
|
||||||
|
aml.move_id AS invoice_id,
|
||||||
|
ROUND(SUM(aml.price_total)::numeric, 2) AS shipping_brutto,
|
||||||
|
ROUND(SUM(aml.price_subtotal)::numeric, 2) AS shipping_netto
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN account_move_line aml
|
||||||
|
ON aml.move_id = inv.id
|
||||||
|
JOIN product_product pp
|
||||||
|
ON pp.id = aml.product_id
|
||||||
|
JOIN product_template pt
|
||||||
|
ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pt.categ_id = 9
|
||||||
|
GROUP BY aml.move_id
|
||||||
|
),
|
||||||
|
|
||||||
|
invoice_tags AS (
|
||||||
|
SELECT
|
||||||
|
inv.id AS invoice_id,
|
||||||
|
STRING_AGG(
|
||||||
|
DISTINCT COALESCE(ct.name->>'en_US', ct.name->>'de_DE', ''),
|
||||||
|
', ' ORDER BY COALESCE(ct.name->>'en_US', ct.name->>'de_DE', '')
|
||||||
|
) AS tags
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN sale_order so
|
||||||
|
ON so.name = inv.invoice_origin
|
||||||
|
JOIN sale_order_tag_rel sotr
|
||||||
|
ON sotr.order_id = so.id
|
||||||
|
JOIN crm_tag ct
|
||||||
|
ON ct.id = sotr.tag_id
|
||||||
|
GROUP BY inv.id
|
||||||
|
),
|
||||||
|
|
||||||
|
invoice_transaction_reference AS (
|
||||||
|
SELECT
|
||||||
|
rel.invoice_id,
|
||||||
|
pt.provider_reference,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY rel.invoice_id
|
||||||
|
ORDER BY pt.id
|
||||||
|
) AS rn
|
||||||
|
FROM account_invoice_transaction_rel rel
|
||||||
|
JOIN payment_transaction pt
|
||||||
|
ON pt.id = rel.transaction_id
|
||||||
|
),
|
||||||
|
|
||||||
|
sale_info AS (
|
||||||
|
SELECT
|
||||||
|
inv.id AS invoice_id,
|
||||||
|
MIN(so.date_order) AS order_date
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN sale_order so
|
||||||
|
ON so.name = inv.invoice_origin
|
||||||
|
GROUP BY inv.id
|
||||||
|
),
|
||||||
|
|
||||||
|
invoice_payments AS (
|
||||||
|
SELECT
|
||||||
|
aml.move_id AS invoice_id,
|
||||||
|
MAX(apr.max_date) AS payment_date
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN account_move_line aml
|
||||||
|
ON aml.move_id = inv.id
|
||||||
|
JOIN account_account aa
|
||||||
|
ON aa.id = aml.account_id
|
||||||
|
AND aa.account_type = 'asset_receivable'
|
||||||
|
JOIN account_partial_reconcile apr
|
||||||
|
ON apr.debit_move_id = aml.id
|
||||||
|
OR apr.credit_move_id = aml.id
|
||||||
|
GROUP BY aml.move_id
|
||||||
|
),
|
||||||
|
|
||||||
|
odoo_rows AS (
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(inv.invoice_date, 'YYYY-MM-DD') AS "Rechnungsdatum",
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN inv.move_type = 'out_refund'
|
||||||
|
THEN COALESCE(inv.name, inv.ref, '') || '-gs'
|
||||||
|
ELSE COALESCE(inv.name, inv.ref, '')
|
||||||
|
END AS "Rechnungsnummer",
|
||||||
|
|
||||||
|
COALESCE(rc_inv.name->>'en_US', rc_inv.name->>'de_DE', '') AS "Rechnungsland",
|
||||||
|
COALESCE(rc_inv.code, '') AS "Rechnungsland Ländercode",
|
||||||
|
COALESCE(apt.name->>'en_US', apt.name->>'de_DE', '') AS "Zahlart",
|
||||||
|
|
||||||
|
COALESCE(itr.provider_reference, '') AS "Zahlungsreference der Bestellung",
|
||||||
|
COALESCE(partner.vat, '') AS "USt. Id / VAT Id des Käufers (in Bestellung)",
|
||||||
|
|
||||||
|
ROUND(COALESCE(ts.brutto_normal, 0) + COALESCE(ts.brutto_erm, 0), 2) AS "Gesamtpreis brutto",
|
||||||
|
ROUND(COALESCE(ts.netto_normal, 0) + COALESCE(ts.netto_erm, 0), 2) AS "Gesamtpreis netto",
|
||||||
|
|
||||||
|
COALESCE(ts.brutto_normal, 0) AS "Brutto",
|
||||||
|
COALESCE(ts.brutto_erm, 0) AS "Brutto erm",
|
||||||
|
COALESCE(ts.ust_satz_normal, 0) AS "USt. Satz",
|
||||||
|
COALESCE(ts.ust_satz_erm, 0) AS "Ust. Satz erm",
|
||||||
|
COALESCE(ts.netto_normal, 0) AS "Netto Betrag",
|
||||||
|
COALESCE(ts.netto_erm, 0) AS "Netto Betrag erm",
|
||||||
|
|
||||||
|
COALESCE(sh.shipping_brutto, 0) AS "Versandkosten brutto",
|
||||||
|
COALESCE(sh.shipping_netto, 0) AS "Versandkosten netto",
|
||||||
|
|
||||||
|
COALESCE(TO_CHAR(si.order_date, 'YYYY-MM-DD'), '') AS "Auftragsdatum",
|
||||||
|
COALESCE(inv.invoice_origin, '') AS "Bestellnummer",
|
||||||
|
COALESCE(TO_CHAR(ip.payment_date, 'YYYY-MM-DD'), '') AS "Zahldatum",
|
||||||
|
|
||||||
|
COALESCE(ship.name, partner.name, '') AS "Lieferadresse Name",
|
||||||
|
COALESCE(it.tags, '') AS "Tag",
|
||||||
|
COALESCE(
|
||||||
|
rc_ship.name->>'en_US', rc_ship.name->>'de_DE',
|
||||||
|
rc_inv.name->>'en_US', rc_inv.name->>'de_DE',
|
||||||
|
''
|
||||||
|
) AS "Lieferland"
|
||||||
|
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN res_partner partner
|
||||||
|
ON partner.id = inv.partner_id
|
||||||
|
LEFT JOIN res_country rc_inv
|
||||||
|
ON rc_inv.id = partner.country_id
|
||||||
|
LEFT JOIN res_partner ship
|
||||||
|
ON ship.id = inv.partner_shipping_id
|
||||||
|
LEFT JOIN res_country rc_ship
|
||||||
|
ON rc_ship.id = ship.country_id
|
||||||
|
LEFT JOIN account_payment_term apt
|
||||||
|
ON apt.id = inv.invoice_payment_term_id
|
||||||
|
LEFT JOIN tax_split ts
|
||||||
|
ON ts.invoice_id = inv.id
|
||||||
|
LEFT JOIN shipping sh
|
||||||
|
ON sh.invoice_id = inv.id
|
||||||
|
LEFT JOIN invoice_tags it
|
||||||
|
ON it.invoice_id = inv.id
|
||||||
|
LEFT JOIN invoice_transaction_reference itr
|
||||||
|
ON itr.invoice_id = inv.id
|
||||||
|
AND itr.rn = 1
|
||||||
|
LEFT JOIN sale_info si
|
||||||
|
ON si.invoice_id = inv.id
|
||||||
|
LEFT JOIN invoice_payments ip
|
||||||
|
ON ip.invoice_id = inv.id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- AMAZON-Seite
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
-- Basiszahlen + effektiver USt-Satz je Transaktionszeile.
|
||||||
|
amazon_base AS (
|
||||||
|
SELECT
|
||||||
|
a.transaction_complete_date,
|
||||||
|
a.transaction_depart_date,
|
||||||
|
a.vat_inv_number,
|
||||||
|
a.sale_arrival_country,
|
||||||
|
a.arrival_country,
|
||||||
|
a.transaction_event_id,
|
||||||
|
a.buyer_vat_number,
|
||||||
|
a.buyer_name,
|
||||||
|
a.activity_transaction_id,
|
||||||
|
COALESCE(a.total_activity_value_amt_vat_incl, 0) AS gross,
|
||||||
|
COALESCE(a.total_activity_value_amt_vat_excl, 0) AS net,
|
||||||
|
COALESCE(a.total_ship_charge_amt_vat_incl, 0) AS ship_gross,
|
||||||
|
COALESCE(a.total_ship_charge_amt_vat_excl, 0) AS ship_net,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(a.total_activity_value_amt_vat_excl, 0) <> 0
|
||||||
|
THEN ROUND((COALESCE(a.total_activity_value_vat_amt, 0)
|
||||||
|
/ a.total_activity_value_amt_vat_excl * 100)::numeric, 2)
|
||||||
|
ELSE 0
|
||||||
|
END AS rate
|
||||||
|
FROM brandize_datacenter_amazon_vat_transaction a
|
||||||
|
WHERE a.activity_period = %(month)s
|
||||||
|
-- Optional: Gebuehrenzeilen ausschliessen (wie in VAT_Summary.sql):
|
||||||
|
-- AND a.sales_channel <> 'AMAZON_FEE'
|
||||||
|
),
|
||||||
|
|
||||||
|
amazon_rows AS (
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(COALESCE(b.transaction_complete_date, b.transaction_depart_date), 'YYYY-MM-DD')
|
||||||
|
AS "Rechnungsdatum",
|
||||||
|
COALESCE(b.vat_inv_number, '') AS "Rechnungsnummer",
|
||||||
|
COALESCE(rc_inv.name->>'en_US', rc_inv.name->>'de_DE', '') AS "Rechnungsland",
|
||||||
|
COALESCE(b.sale_arrival_country, '') AS "Rechnungsland Ländercode",
|
||||||
|
'' AS "Zahlart",
|
||||||
|
COALESCE(b.transaction_event_id, '') AS "Zahlungsreference der Bestellung",
|
||||||
|
COALESCE(b.buyer_vat_number, '') AS "USt. Id / VAT Id des Käufers (in Bestellung)",
|
||||||
|
|
||||||
|
ROUND(b.gross::numeric, 2) AS "Gesamtpreis brutto",
|
||||||
|
ROUND(b.net::numeric, 2) AS "Gesamtpreis netto",
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
THEN ROUND(b.gross::numeric, 2) ELSE 0
|
||||||
|
END AS "Brutto",
|
||||||
|
CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(b.gross::numeric, 2) ELSE 0
|
||||||
|
END AS "Brutto erm",
|
||||||
|
CASE
|
||||||
|
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
THEN b.rate ELSE 0
|
||||||
|
END AS "USt. Satz",
|
||||||
|
CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
)
|
||||||
|
THEN b.rate ELSE 0
|
||||||
|
END AS "Ust. Satz erm",
|
||||||
|
CASE
|
||||||
|
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
THEN ROUND(b.net::numeric, 2) ELSE 0
|
||||||
|
END AS "Netto Betrag",
|
||||||
|
CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(b.net::numeric, 2) ELSE 0
|
||||||
|
END AS "Netto Betrag erm",
|
||||||
|
|
||||||
|
ROUND(b.ship_gross::numeric, 2) AS "Versandkosten brutto",
|
||||||
|
ROUND(b.ship_net::numeric, 2) AS "Versandkosten netto",
|
||||||
|
|
||||||
|
COALESCE(TO_CHAR(b.transaction_depart_date, 'YYYY-MM-DD'), '') AS "Auftragsdatum",
|
||||||
|
COALESCE(b.activity_transaction_id, '') AS "Bestellnummer",
|
||||||
|
'' AS "Zahldatum",
|
||||||
|
COALESCE(b.buyer_name, '') AS "Lieferadresse Name",
|
||||||
|
'' AS "Tag",
|
||||||
|
COALESCE(rc_ship.name->>'en_US', rc_ship.name->>'de_DE', '') AS "Lieferland"
|
||||||
|
|
||||||
|
FROM amazon_base b
|
||||||
|
LEFT JOIN res_country rc_inv
|
||||||
|
ON rc_inv.code = b.sale_arrival_country
|
||||||
|
LEFT JOIN res_country rc_ship
|
||||||
|
ON rc_ship.code = b.arrival_country
|
||||||
|
)
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- Zusammenfuehrung: ODOO + AMAZON, je Zeile mit Quelle
|
||||||
|
-- ===========================================================================
|
||||||
|
SELECT *, 'ODOO' AS "Quelle" FROM odoo_rows
|
||||||
|
UNION ALL
|
||||||
|
SELECT *, 'AMAZON' AS "Quelle" FROM amazon_rows
|
||||||
|
ORDER BY "Rechnungsdatum", "Rechnungsnummer";
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Quartal Schweiz – Gesamt (ODOO + AMAZON), Lieferung in die Schweiz
|
||||||
|
-- Odoo 17 / PostgreSQL
|
||||||
|
--
|
||||||
|
-- Zweck:
|
||||||
|
-- Dieselben Spalten wie Alle_Rechnungen.sql (inkl. "Quelle"), aber:
|
||||||
|
-- * Zeitraum = QUARTAL statt Monat
|
||||||
|
-- * nur Rechnungen mit Lieferung in die SCHWEIZ (Lieferland = 'CH')
|
||||||
|
--
|
||||||
|
-- Modelle:
|
||||||
|
-- ODOO : account.move (Kundenrechnungen / Gutschriften)
|
||||||
|
-- AMAZON : brandize_datacenter_amazon_vat_transaction
|
||||||
|
--
|
||||||
|
-- Parameter:
|
||||||
|
-- %(quarter)s Quartal als Text, z. B. '2026-Q3'
|
||||||
|
-- ODOO : Rechnungsdatum im Quartal (siehe quarters-CTE)
|
||||||
|
-- AMAZON: activity_period in den 3 Monaten des Quartals
|
||||||
|
--
|
||||||
|
-- Aufruf (Odoo / psycopg2):
|
||||||
|
-- cr.execute(open('Quartal_Schweiz.sql').read(), {'quarter': '2026-Q3'})
|
||||||
|
--
|
||||||
|
-- In psql stattdessen:
|
||||||
|
-- \set quarter '2026-Q3'
|
||||||
|
--
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Spalten-Mapping ODOO -> AMAZON
|
||||||
|
-- (Felder ohne Amazon-Pendant bleiben leer)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Rechnungsdatum <- transaction_complete_date (Fallback: depart)
|
||||||
|
-- Rechnungsnummer <- vat_inv_number
|
||||||
|
-- Rechnungsland <- Name zu sale_arrival_country (res_country)
|
||||||
|
-- Rechnungsland Code <- sale_arrival_country
|
||||||
|
-- Zahlart <- '' (kein Amazon-Feld)
|
||||||
|
-- Zahlungsreference <- transaction_event_id
|
||||||
|
-- USt. Id Kaeufer <- buyer_vat_number
|
||||||
|
-- Gesamtpreis brutto <- total_activity_value_amt_vat_incl
|
||||||
|
-- Gesamtpreis netto <- total_activity_value_amt_vat_excl
|
||||||
|
-- Brutto / -erm <- Steuersplit nach effektivem Satz (Schwelle
|
||||||
|
-- USt. Satz / -erm CH >= 8, sonst >= 16 - wie im ODOO-Export)
|
||||||
|
-- Netto Betrag / -erm Satz = total_activity_value_vat_amt / netto * 100
|
||||||
|
-- Versandkosten brutto <- total_ship_charge_amt_vat_incl
|
||||||
|
-- Versandkosten netto <- total_ship_charge_amt_vat_excl
|
||||||
|
-- Auftragsdatum <- transaction_depart_date
|
||||||
|
-- Bestellnummer <- activity_transaction_id
|
||||||
|
-- Zahldatum <- '' (kein Amazon-Feld)
|
||||||
|
-- Lieferadresse Name <- buyer_name
|
||||||
|
-- Tag <- '' (kein Amazon-Feld)
|
||||||
|
-- Lieferland <- Name zu arrival_country (res_country)
|
||||||
|
--
|
||||||
|
-- ACHTUNG: Das Amazon-Mapping ist eine 1:1-Uebertragung auf die ODOO-Spalten.
|
||||||
|
-- Die Datumszuordnung (Rechnungsdatum/Auftragsdatum) sowie Bestellnummer
|
||||||
|
-- sollten gegen die tatsaechliche Amazon-Report-Semantik geprueft und hier
|
||||||
|
-- ggf. angepasst werden.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
WITH params AS (
|
||||||
|
-- Quartal aus %(quarter)s (z. B. '2026-Q3') ableiten.
|
||||||
|
SELECT
|
||||||
|
split_part(%(quarter)s, '-', 1) AS yr,
|
||||||
|
right(split_part(%(quarter)s, '-', 2), 1)::int AS qn
|
||||||
|
),
|
||||||
|
quarters AS (
|
||||||
|
SELECT
|
||||||
|
yr,
|
||||||
|
qn,
|
||||||
|
make_date(yr::int, (qn - 1) * 3 + 1, 1) AS first_day,
|
||||||
|
make_date(yr::int, (qn - 1) * 3 + 1, 1) + INTERVAL '3 months' AS next_day
|
||||||
|
FROM params
|
||||||
|
),
|
||||||
|
-- Die drei activity_period-Werte (Format 'YYYY-MMM', deutsche Kuerzel) fuer die
|
||||||
|
-- Amazon-Tabelle.
|
||||||
|
qmonths AS (
|
||||||
|
SELECT
|
||||||
|
q.yr || '-' || CASE ((q.qn - 1) * 3 + m.m)
|
||||||
|
WHEN 1 THEN 'JAN' WHEN 2 THEN 'FEB' WHEN 3 THEN 'MÄR'
|
||||||
|
WHEN 4 THEN 'APR' WHEN 5 THEN 'MAI' WHEN 6 THEN 'JUN'
|
||||||
|
WHEN 7 THEN 'JUL' WHEN 8 THEN 'AUG' WHEN 9 THEN 'SEP'
|
||||||
|
WHEN 10 THEN 'OKT' WHEN 11 THEN 'NOV' WHEN 12 THEN 'DEZ'
|
||||||
|
END AS activity_period
|
||||||
|
FROM quarters q
|
||||||
|
CROSS JOIN generate_series(1, 3) AS m(m)
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- ODOO-Seite (identisch zu Rechnungsexport.sql)
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
invoices AS (
|
||||||
|
SELECT
|
||||||
|
am.id,
|
||||||
|
am.name,
|
||||||
|
am.ref,
|
||||||
|
am.move_type,
|
||||||
|
am.invoice_date,
|
||||||
|
am.invoice_origin,
|
||||||
|
am.partner_id,
|
||||||
|
am.partner_shipping_id,
|
||||||
|
am.invoice_payment_term_id,
|
||||||
|
am.payment_reference
|
||||||
|
FROM account_move am
|
||||||
|
CROSS JOIN quarters q
|
||||||
|
WHERE am.invoice_date >= q.first_day
|
||||||
|
AND am.invoice_date < q.next_day
|
||||||
|
AND am.move_type IN ('out_invoice', 'out_refund')
|
||||||
|
),
|
||||||
|
|
||||||
|
line_taxes AS (
|
||||||
|
SELECT
|
||||||
|
aml.move_id AS invoice_id,
|
||||||
|
at.amount AS tax_rate,
|
||||||
|
SUM(aml.price_subtotal) AS netto,
|
||||||
|
SUM(aml.price_total) AS brutto
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN account_move_line aml
|
||||||
|
ON aml.move_id = inv.id
|
||||||
|
JOIN account_move_line_account_tax_rel amlatr
|
||||||
|
ON amlatr.account_move_line_id = aml.id
|
||||||
|
JOIN account_tax at
|
||||||
|
ON at.id = amlatr.account_tax_id
|
||||||
|
GROUP BY aml.move_id, at.amount
|
||||||
|
),
|
||||||
|
|
||||||
|
tax_split AS (
|
||||||
|
SELECT
|
||||||
|
lt.invoice_id,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
THEN ROUND(lt.brutto::numeric, 2)
|
||||||
|
END) AS brutto_normal,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
THEN ROUND(lt.tax_rate::numeric, 2)
|
||||||
|
END) AS ust_satz_normal,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
THEN ROUND(lt.netto::numeric, 2)
|
||||||
|
END) AS netto_normal,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(lt.brutto::numeric, 2)
|
||||||
|
END) AS brutto_erm,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(lt.tax_rate::numeric, 2)
|
||||||
|
END) AS ust_satz_erm,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(lt.netto::numeric, 2)
|
||||||
|
END) AS netto_erm
|
||||||
|
|
||||||
|
FROM line_taxes lt
|
||||||
|
JOIN invoices inv
|
||||||
|
ON inv.id = lt.invoice_id
|
||||||
|
JOIN res_partner rp
|
||||||
|
ON rp.id = inv.partner_id
|
||||||
|
LEFT JOIN res_country rc
|
||||||
|
ON rc.id = rp.country_id
|
||||||
|
GROUP BY lt.invoice_id
|
||||||
|
),
|
||||||
|
|
||||||
|
shipping AS (
|
||||||
|
SELECT
|
||||||
|
aml.move_id AS invoice_id,
|
||||||
|
ROUND(SUM(aml.price_total)::numeric, 2) AS shipping_brutto,
|
||||||
|
ROUND(SUM(aml.price_subtotal)::numeric, 2) AS shipping_netto
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN account_move_line aml
|
||||||
|
ON aml.move_id = inv.id
|
||||||
|
JOIN product_product pp
|
||||||
|
ON pp.id = aml.product_id
|
||||||
|
JOIN product_template pt
|
||||||
|
ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pt.categ_id = 9
|
||||||
|
GROUP BY aml.move_id
|
||||||
|
),
|
||||||
|
|
||||||
|
invoice_tags AS (
|
||||||
|
SELECT
|
||||||
|
inv.id AS invoice_id,
|
||||||
|
STRING_AGG(
|
||||||
|
DISTINCT COALESCE(ct.name->>'en_US', ct.name->>'de_DE', ''),
|
||||||
|
', ' ORDER BY COALESCE(ct.name->>'en_US', ct.name->>'de_DE', '')
|
||||||
|
) AS tags
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN sale_order so
|
||||||
|
ON so.name = inv.invoice_origin
|
||||||
|
JOIN sale_order_tag_rel sotr
|
||||||
|
ON sotr.order_id = so.id
|
||||||
|
JOIN crm_tag ct
|
||||||
|
ON ct.id = sotr.tag_id
|
||||||
|
GROUP BY inv.id
|
||||||
|
),
|
||||||
|
|
||||||
|
invoice_transaction_reference AS (
|
||||||
|
SELECT
|
||||||
|
rel.invoice_id,
|
||||||
|
pt.provider_reference,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY rel.invoice_id
|
||||||
|
ORDER BY pt.id
|
||||||
|
) AS rn
|
||||||
|
FROM account_invoice_transaction_rel rel
|
||||||
|
JOIN payment_transaction pt
|
||||||
|
ON pt.id = rel.transaction_id
|
||||||
|
),
|
||||||
|
|
||||||
|
sale_info AS (
|
||||||
|
SELECT
|
||||||
|
inv.id AS invoice_id,
|
||||||
|
MIN(so.date_order) AS order_date
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN sale_order so
|
||||||
|
ON so.name = inv.invoice_origin
|
||||||
|
GROUP BY inv.id
|
||||||
|
),
|
||||||
|
|
||||||
|
invoice_payments AS (
|
||||||
|
SELECT
|
||||||
|
aml.move_id AS invoice_id,
|
||||||
|
MAX(apr.max_date) AS payment_date
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN account_move_line aml
|
||||||
|
ON aml.move_id = inv.id
|
||||||
|
JOIN account_account aa
|
||||||
|
ON aa.id = aml.account_id
|
||||||
|
AND aa.account_type = 'asset_receivable'
|
||||||
|
JOIN account_partial_reconcile apr
|
||||||
|
ON apr.debit_move_id = aml.id
|
||||||
|
OR apr.credit_move_id = aml.id
|
||||||
|
GROUP BY aml.move_id
|
||||||
|
),
|
||||||
|
|
||||||
|
odoo_rows AS (
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(inv.invoice_date, 'YYYY-MM-DD') AS "Rechnungsdatum",
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN inv.move_type = 'out_refund'
|
||||||
|
THEN COALESCE(inv.name, inv.ref, '') || '-gs'
|
||||||
|
ELSE COALESCE(inv.name, inv.ref, '')
|
||||||
|
END AS "Rechnungsnummer",
|
||||||
|
|
||||||
|
COALESCE(rc_inv.name->>'en_US', rc_inv.name->>'de_DE', '') AS "Rechnungsland",
|
||||||
|
COALESCE(rc_inv.code, '') AS "Rechnungsland Ländercode",
|
||||||
|
COALESCE(apt.name->>'en_US', apt.name->>'de_DE', '') AS "Zahlart",
|
||||||
|
|
||||||
|
COALESCE(itr.provider_reference, '') AS "Zahlungsreference der Bestellung",
|
||||||
|
COALESCE(partner.vat, '') AS "USt. Id / VAT Id des Käufers (in Bestellung)",
|
||||||
|
|
||||||
|
ROUND(COALESCE(ts.brutto_normal, 0) + COALESCE(ts.brutto_erm, 0), 2) AS "Gesamtpreis brutto",
|
||||||
|
ROUND(COALESCE(ts.netto_normal, 0) + COALESCE(ts.netto_erm, 0), 2) AS "Gesamtpreis netto",
|
||||||
|
|
||||||
|
COALESCE(ts.brutto_normal, 0) AS "Brutto",
|
||||||
|
COALESCE(ts.brutto_erm, 0) AS "Brutto erm",
|
||||||
|
COALESCE(ts.ust_satz_normal, 0) AS "USt. Satz",
|
||||||
|
COALESCE(ts.ust_satz_erm, 0) AS "Ust. Satz erm",
|
||||||
|
COALESCE(ts.netto_normal, 0) AS "Netto Betrag",
|
||||||
|
COALESCE(ts.netto_erm, 0) AS "Netto Betrag erm",
|
||||||
|
|
||||||
|
COALESCE(sh.shipping_brutto, 0) AS "Versandkosten brutto",
|
||||||
|
COALESCE(sh.shipping_netto, 0) AS "Versandkosten netto",
|
||||||
|
|
||||||
|
COALESCE(TO_CHAR(si.order_date, 'YYYY-MM-DD'), '') AS "Auftragsdatum",
|
||||||
|
COALESCE(inv.invoice_origin, '') AS "Bestellnummer",
|
||||||
|
COALESCE(TO_CHAR(ip.payment_date, 'YYYY-MM-DD'), '') AS "Zahldatum",
|
||||||
|
|
||||||
|
COALESCE(ship.name, partner.name, '') AS "Lieferadresse Name",
|
||||||
|
COALESCE(it.tags, '') AS "Tag",
|
||||||
|
COALESCE(
|
||||||
|
rc_ship.name->>'en_US', rc_ship.name->>'de_DE',
|
||||||
|
rc_inv.name->>'en_US', rc_inv.name->>'de_DE',
|
||||||
|
''
|
||||||
|
) AS "Lieferland"
|
||||||
|
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN res_partner partner
|
||||||
|
ON partner.id = inv.partner_id
|
||||||
|
LEFT JOIN res_country rc_inv
|
||||||
|
ON rc_inv.id = partner.country_id
|
||||||
|
LEFT JOIN res_partner ship
|
||||||
|
ON ship.id = inv.partner_shipping_id
|
||||||
|
LEFT JOIN res_country rc_ship
|
||||||
|
ON rc_ship.id = ship.country_id
|
||||||
|
LEFT JOIN account_payment_term apt
|
||||||
|
ON apt.id = inv.invoice_payment_term_id
|
||||||
|
LEFT JOIN tax_split ts
|
||||||
|
ON ts.invoice_id = inv.id
|
||||||
|
LEFT JOIN shipping sh
|
||||||
|
ON sh.invoice_id = inv.id
|
||||||
|
LEFT JOIN invoice_tags it
|
||||||
|
ON it.invoice_id = inv.id
|
||||||
|
LEFT JOIN invoice_transaction_reference itr
|
||||||
|
ON itr.invoice_id = inv.id
|
||||||
|
AND itr.rn = 1
|
||||||
|
LEFT JOIN sale_info si
|
||||||
|
ON si.invoice_id = inv.id
|
||||||
|
LEFT JOIN invoice_payments ip
|
||||||
|
ON ip.invoice_id = inv.id
|
||||||
|
-- Nur Lieferungen in die Schweiz (Versandland, Fallback: Rechnungsland).
|
||||||
|
WHERE COALESCE(rc_ship.code, rc_inv.code) = 'CH'
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- AMAZON-Seite
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
-- Basiszahlen + effektiver USt-Satz je Transaktionszeile.
|
||||||
|
amazon_base AS (
|
||||||
|
SELECT
|
||||||
|
a.transaction_complete_date,
|
||||||
|
a.transaction_depart_date,
|
||||||
|
a.vat_inv_number,
|
||||||
|
a.sale_arrival_country,
|
||||||
|
a.arrival_country,
|
||||||
|
a.transaction_event_id,
|
||||||
|
a.buyer_vat_number,
|
||||||
|
a.buyer_name,
|
||||||
|
a.activity_transaction_id,
|
||||||
|
COALESCE(a.total_activity_value_amt_vat_incl, 0) AS gross,
|
||||||
|
COALESCE(a.total_activity_value_amt_vat_excl, 0) AS net,
|
||||||
|
COALESCE(a.total_ship_charge_amt_vat_incl, 0) AS ship_gross,
|
||||||
|
COALESCE(a.total_ship_charge_amt_vat_excl, 0) AS ship_net,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(a.total_activity_value_amt_vat_excl, 0) <> 0
|
||||||
|
THEN ROUND((COALESCE(a.total_activity_value_vat_amt, 0)
|
||||||
|
/ a.total_activity_value_amt_vat_excl * 100)::numeric, 2)
|
||||||
|
ELSE 0
|
||||||
|
END AS rate
|
||||||
|
FROM brandize_datacenter_amazon_vat_transaction a
|
||||||
|
WHERE a.activity_period IN (SELECT activity_period FROM qmonths)
|
||||||
|
AND a.arrival_country = 'CH'
|
||||||
|
-- Optional: Gebuehrenzeilen ausschliessen (wie in VAT_Summary.sql):
|
||||||
|
-- AND a.sales_channel <> 'AMAZON_FEE'
|
||||||
|
),
|
||||||
|
|
||||||
|
amazon_rows AS (
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(COALESCE(b.transaction_complete_date, b.transaction_depart_date), 'YYYY-MM-DD')
|
||||||
|
AS "Rechnungsdatum",
|
||||||
|
COALESCE(b.vat_inv_number, '') AS "Rechnungsnummer",
|
||||||
|
COALESCE(rc_inv.name->>'en_US', rc_inv.name->>'de_DE', '') AS "Rechnungsland",
|
||||||
|
COALESCE(b.sale_arrival_country, '') AS "Rechnungsland Ländercode",
|
||||||
|
'' AS "Zahlart",
|
||||||
|
COALESCE(b.transaction_event_id, '') AS "Zahlungsreference der Bestellung",
|
||||||
|
COALESCE(b.buyer_vat_number, '') AS "USt. Id / VAT Id des Käufers (in Bestellung)",
|
||||||
|
|
||||||
|
ROUND(b.gross::numeric, 2) AS "Gesamtpreis brutto",
|
||||||
|
ROUND(b.net::numeric, 2) AS "Gesamtpreis netto",
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
THEN ROUND(b.gross::numeric, 2) ELSE 0
|
||||||
|
END AS "Brutto",
|
||||||
|
CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(b.gross::numeric, 2) ELSE 0
|
||||||
|
END AS "Brutto erm",
|
||||||
|
CASE
|
||||||
|
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
THEN b.rate ELSE 0
|
||||||
|
END AS "USt. Satz",
|
||||||
|
CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
)
|
||||||
|
THEN b.rate ELSE 0
|
||||||
|
END AS "Ust. Satz erm",
|
||||||
|
CASE
|
||||||
|
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
THEN ROUND(b.net::numeric, 2) ELSE 0
|
||||||
|
END AS "Netto Betrag",
|
||||||
|
CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
|
||||||
|
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(b.net::numeric, 2) ELSE 0
|
||||||
|
END AS "Netto Betrag erm",
|
||||||
|
|
||||||
|
ROUND(b.ship_gross::numeric, 2) AS "Versandkosten brutto",
|
||||||
|
ROUND(b.ship_net::numeric, 2) AS "Versandkosten netto",
|
||||||
|
|
||||||
|
COALESCE(TO_CHAR(b.transaction_depart_date, 'YYYY-MM-DD'), '') AS "Auftragsdatum",
|
||||||
|
COALESCE(b.activity_transaction_id, '') AS "Bestellnummer",
|
||||||
|
'' AS "Zahldatum",
|
||||||
|
COALESCE(b.buyer_name, '') AS "Lieferadresse Name",
|
||||||
|
'' AS "Tag",
|
||||||
|
COALESCE(rc_ship.name->>'en_US', rc_ship.name->>'de_DE', '') AS "Lieferland"
|
||||||
|
|
||||||
|
FROM amazon_base b
|
||||||
|
LEFT JOIN res_country rc_inv
|
||||||
|
ON rc_inv.code = b.sale_arrival_country
|
||||||
|
LEFT JOIN res_country rc_ship
|
||||||
|
ON rc_ship.code = b.arrival_country
|
||||||
|
)
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- Zusammenfuehrung: ODOO + AMAZON, je Zeile mit Quelle
|
||||||
|
-- ===========================================================================
|
||||||
|
SELECT *, 'ODOO' AS "Quelle" FROM odoo_rows
|
||||||
|
UNION ALL
|
||||||
|
SELECT *, 'AMAZON' AS "Quelle" FROM amazon_rows
|
||||||
|
ORDER BY "Rechnungsdatum", "Rechnungsnummer";
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Rechnungsexport – Odoo 17 / PostgreSQL
|
||||||
|
-- Rechnungsdatum · Rechnungsnummer · Länder · Zahlart · USt-Nr. · Steuersplit
|
||||||
|
--
|
||||||
|
-- Modell : account.move (Kundenrechnungen und Gutschriften)
|
||||||
|
--
|
||||||
|
-- Parameter:
|
||||||
|
-- %(month)s Abrechnungszeitraum als Text, z. B. '2026-SEP'
|
||||||
|
--
|
||||||
|
-- Aufruf (Odoo / psycopg2):
|
||||||
|
-- cr.execute(open('Rechnungsexport.sql').read(), {'month': '2026-SEP'})
|
||||||
|
--
|
||||||
|
-- In psql stattdessen:
|
||||||
|
-- \set month '2026-SEP'
|
||||||
|
-- ... AND am.invoice_date >= :'month' (siehe params-CTE)
|
||||||
|
--
|
||||||
|
-- Hinweis zum Monatsfilter:
|
||||||
|
-- Das Rechnungsdatum wird auf einen vollen Kalendermonat gefiltert.
|
||||||
|
-- Der Monat wird locale-unabhängig in ein Datum umgerechnet; es werden
|
||||||
|
-- deutsche (MÄR, MAI, OKT, DEZ) und englische (MAR, MAY, OCT, DEC)
|
||||||
|
-- Dreibuchstaben-Kürzel akzeptiert.
|
||||||
|
--
|
||||||
|
-- Änderungen gegenüber der Vorversion (nur die fehlenden Felder ergänzt):
|
||||||
|
-- * Auftragsdatum : aus sale_order.date_order (über invoice_origin)
|
||||||
|
-- * Zahldatum : aus der letzten Ausgleichsbuchung der Debitorenzeile
|
||||||
|
-- * Lieferadresse Name / Lieferland : aus account_move.partner_shipping_id
|
||||||
|
-- (Fallback: Rechnungspartner)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
WITH params AS (
|
||||||
|
-- Kalendermonat aus %(month)s (z. B. '2026-SEP') ableiten.
|
||||||
|
SELECT
|
||||||
|
to_date(
|
||||||
|
split_part(%(month)s, '-', 1) || '-' ||
|
||||||
|
CASE upper(split_part(%(month)s, '-', 2))
|
||||||
|
WHEN 'JAN' THEN '01' WHEN 'FEB' THEN '02'
|
||||||
|
WHEN 'MÄR' THEN '03' WHEN 'MAR' THEN '03' WHEN 'APR' THEN '04'
|
||||||
|
WHEN 'MAI' THEN '05' WHEN 'MAY' THEN '05' WHEN 'JUN' THEN '06'
|
||||||
|
WHEN 'JUL' THEN '07' WHEN 'AUG' THEN '08' WHEN 'SEP' THEN '09'
|
||||||
|
WHEN 'OKT' THEN '10' WHEN 'OCT' THEN '10' WHEN 'NOV' THEN '11'
|
||||||
|
WHEN 'DEZ' THEN '12' WHEN 'DEC' THEN '12'
|
||||||
|
END,
|
||||||
|
'YYYY-MM'
|
||||||
|
) AS first_day
|
||||||
|
),
|
||||||
|
|
||||||
|
invoices AS (
|
||||||
|
SELECT
|
||||||
|
am.id,
|
||||||
|
am.name,
|
||||||
|
am.ref,
|
||||||
|
am.move_type,
|
||||||
|
am.invoice_date,
|
||||||
|
am.invoice_origin,
|
||||||
|
am.partner_id,
|
||||||
|
am.partner_shipping_id,
|
||||||
|
am.invoice_payment_term_id,
|
||||||
|
am.payment_reference
|
||||||
|
FROM account_move am
|
||||||
|
CROSS JOIN params p
|
||||||
|
WHERE am.invoice_date >= p.first_day
|
||||||
|
AND am.invoice_date < p.first_day + INTERVAL '1 month'
|
||||||
|
AND am.move_type IN ('out_invoice', 'out_refund')
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- tax summary: invoice line -> tax relation -> account_tax
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
line_taxes AS (
|
||||||
|
SELECT
|
||||||
|
aml.move_id AS invoice_id,
|
||||||
|
at.amount AS tax_rate,
|
||||||
|
SUM(aml.price_subtotal) AS netto,
|
||||||
|
SUM(aml.price_total) AS brutto
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN account_move_line aml
|
||||||
|
ON aml.move_id = inv.id
|
||||||
|
JOIN account_move_line_account_tax_rel amlatr
|
||||||
|
ON amlatr.account_move_line_id = aml.id
|
||||||
|
JOIN account_tax at
|
||||||
|
ON at.id = amlatr.account_tax_id
|
||||||
|
GROUP BY aml.move_id, at.amount
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- tax split logic from Python:
|
||||||
|
-- threshold = 8 for CH, else 16
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
tax_split AS (
|
||||||
|
SELECT
|
||||||
|
lt.invoice_id,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
THEN ROUND(lt.brutto::numeric, 2)
|
||||||
|
END) AS brutto_normal,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
THEN ROUND(lt.tax_rate::numeric, 2)
|
||||||
|
END) AS ust_satz_normal,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
THEN ROUND(lt.netto::numeric, 2)
|
||||||
|
END) AS netto_normal,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(lt.brutto::numeric, 2)
|
||||||
|
END) AS brutto_erm,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(lt.tax_rate::numeric, 2)
|
||||||
|
END) AS ust_satz_erm,
|
||||||
|
|
||||||
|
MAX(CASE
|
||||||
|
WHEN NOT (
|
||||||
|
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
|
||||||
|
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
|
||||||
|
)
|
||||||
|
THEN ROUND(lt.netto::numeric, 2)
|
||||||
|
END) AS netto_erm
|
||||||
|
|
||||||
|
FROM line_taxes lt
|
||||||
|
JOIN invoices inv
|
||||||
|
ON inv.id = lt.invoice_id
|
||||||
|
JOIN res_partner rp
|
||||||
|
ON rp.id = inv.partner_id
|
||||||
|
LEFT JOIN res_country rc
|
||||||
|
ON rc.id = rp.country_id
|
||||||
|
GROUP BY lt.invoice_id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- shipping costs (product category id = 9, as in Python logic)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
shipping AS (
|
||||||
|
SELECT
|
||||||
|
aml.move_id AS invoice_id,
|
||||||
|
ROUND(SUM(aml.price_total)::numeric, 2) AS shipping_brutto,
|
||||||
|
ROUND(SUM(aml.price_subtotal)::numeric, 2) AS shipping_netto
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN account_move_line aml
|
||||||
|
ON aml.move_id = inv.id
|
||||||
|
JOIN product_product pp
|
||||||
|
ON pp.id = aml.product_id
|
||||||
|
JOIN product_template pt
|
||||||
|
ON pt.id = pp.product_tmpl_id
|
||||||
|
WHERE pt.categ_id = 9
|
||||||
|
GROUP BY aml.move_id
|
||||||
|
),
|
||||||
|
|
||||||
|
invoice_tags AS (
|
||||||
|
SELECT
|
||||||
|
inv.id AS invoice_id,
|
||||||
|
STRING_AGG(
|
||||||
|
DISTINCT COALESCE(ct.name->>'en_US', ct.name->>'de_DE', ''),
|
||||||
|
', ' ORDER BY COALESCE(ct.name->>'en_US', ct.name->>'de_DE', '')
|
||||||
|
) AS tags
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN sale_order so
|
||||||
|
ON so.name = inv.invoice_origin
|
||||||
|
JOIN sale_order_tag_rel sotr
|
||||||
|
ON sotr.order_id = so.id
|
||||||
|
JOIN crm_tag ct
|
||||||
|
ON ct.id = sotr.tag_id
|
||||||
|
GROUP BY inv.id
|
||||||
|
),
|
||||||
|
|
||||||
|
invoice_transaction_reference AS (
|
||||||
|
SELECT
|
||||||
|
rel.invoice_id,
|
||||||
|
pt.provider_reference,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY rel.invoice_id
|
||||||
|
ORDER BY pt.id
|
||||||
|
) AS rn
|
||||||
|
FROM account_invoice_transaction_rel rel
|
||||||
|
JOIN payment_transaction pt
|
||||||
|
ON pt.id = rel.transaction_id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Auftragsdatum: Bestellung über invoice_origin (Verkaufsauftragsname) finden
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
sale_info AS (
|
||||||
|
SELECT
|
||||||
|
inv.id AS invoice_id,
|
||||||
|
MIN(so.date_order) AS order_date
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN sale_order so
|
||||||
|
ON so.name = inv.invoice_origin
|
||||||
|
GROUP BY inv.id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Zahldatum: Datum der letzten Ausgleichsbuchung der Debitorenzeile.
|
||||||
|
-- account_partial_reconcile.max_date = jüngeres Datum der beiden Buchungen.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
invoice_payments AS (
|
||||||
|
SELECT
|
||||||
|
aml.move_id AS invoice_id,
|
||||||
|
MAX(apr.max_date) AS payment_date
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN account_move_line aml
|
||||||
|
ON aml.move_id = inv.id
|
||||||
|
JOIN account_account aa
|
||||||
|
ON aa.id = aml.account_id
|
||||||
|
AND aa.account_type = 'asset_receivable'
|
||||||
|
JOIN account_partial_reconcile apr
|
||||||
|
ON apr.debit_move_id = aml.id
|
||||||
|
OR apr.credit_move_id = aml.id
|
||||||
|
GROUP BY aml.move_id
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(inv.invoice_date, 'YYYY-MM-DD') AS "Rechnungsdatum",
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN inv.move_type = 'out_refund'
|
||||||
|
THEN COALESCE(inv.name, inv.ref, '') || '-gs'
|
||||||
|
ELSE COALESCE(inv.name, inv.ref, '')
|
||||||
|
END AS "Rechnungsnummer",
|
||||||
|
|
||||||
|
COALESCE(rc_inv.name->>'en_US', rc_inv.name->>'de_DE', '') AS "Rechnungsland",
|
||||||
|
COALESCE(rc_inv.code, '') AS "Rechnungsland Ländercode",
|
||||||
|
COALESCE(apt.name->>'en_US', apt.name->>'de_DE', '') AS "Zahlart",
|
||||||
|
|
||||||
|
COALESCE(itr.provider_reference, '') AS "Zahlungsreference der Bestellung",
|
||||||
|
COALESCE(partner.vat, '') AS "USt. Id / VAT Id des Käufers (in Bestellung)",
|
||||||
|
|
||||||
|
ROUND(COALESCE(ts.brutto_normal, 0) + COALESCE(ts.brutto_erm, 0), 2) AS "Gesamtpreis brutto",
|
||||||
|
ROUND(COALESCE(ts.netto_normal, 0) + COALESCE(ts.netto_erm, 0), 2) AS "Gesamtpreis netto",
|
||||||
|
|
||||||
|
COALESCE(ts.brutto_normal, 0) AS "Brutto",
|
||||||
|
COALESCE(ts.brutto_erm, 0) AS "Brutto erm",
|
||||||
|
COALESCE(ts.ust_satz_normal, 0) AS "USt. Satz",
|
||||||
|
COALESCE(ts.ust_satz_erm, 0) AS "Ust. Satz erm",
|
||||||
|
COALESCE(ts.netto_normal, 0) AS "Netto Betrag",
|
||||||
|
COALESCE(ts.netto_erm, 0) AS "Netto Betrag erm",
|
||||||
|
|
||||||
|
COALESCE(sh.shipping_brutto, 0) AS "Versandkosten brutto",
|
||||||
|
COALESCE(sh.shipping_netto, 0) AS "Versandkosten netto",
|
||||||
|
|
||||||
|
COALESCE(TO_CHAR(si.order_date, 'YYYY-MM-DD'), '') AS "Auftragsdatum",
|
||||||
|
COALESCE(inv.invoice_origin, '') AS "Bestellnummer",
|
||||||
|
COALESCE(TO_CHAR(ip.payment_date, 'YYYY-MM-DD'), '') AS "Zahldatum",
|
||||||
|
|
||||||
|
COALESCE(ship.name, partner.name, '') AS "Lieferadresse Name",
|
||||||
|
COALESCE(it.tags, '') AS "Tag",
|
||||||
|
COALESCE(
|
||||||
|
rc_ship.name->>'en_US', rc_ship.name->>'de_DE',
|
||||||
|
rc_inv.name->>'en_US', rc_inv.name->>'de_DE',
|
||||||
|
''
|
||||||
|
) AS "Lieferland"
|
||||||
|
|
||||||
|
FROM invoices inv
|
||||||
|
JOIN res_partner partner
|
||||||
|
ON partner.id = inv.partner_id
|
||||||
|
LEFT JOIN res_country rc_inv
|
||||||
|
ON rc_inv.id = partner.country_id
|
||||||
|
LEFT JOIN res_partner ship
|
||||||
|
ON ship.id = inv.partner_shipping_id
|
||||||
|
LEFT JOIN res_country rc_ship
|
||||||
|
ON rc_ship.id = ship.country_id
|
||||||
|
LEFT JOIN account_payment_term apt
|
||||||
|
ON apt.id = inv.invoice_payment_term_id
|
||||||
|
LEFT JOIN tax_split ts
|
||||||
|
ON ts.invoice_id = inv.id
|
||||||
|
LEFT JOIN shipping sh
|
||||||
|
ON sh.invoice_id = inv.id
|
||||||
|
LEFT JOIN invoice_tags it
|
||||||
|
ON it.invoice_id = inv.id
|
||||||
|
LEFT JOIN invoice_transaction_reference itr
|
||||||
|
ON itr.invoice_id = inv.id
|
||||||
|
AND itr.rn = 1
|
||||||
|
LEFT JOIN sale_info si
|
||||||
|
ON si.invoice_id = inv.id
|
||||||
|
LEFT JOIN invoice_payments ip
|
||||||
|
ON ip.invoice_id = inv.id
|
||||||
|
ORDER BY inv.invoice_date, inv.name;
|
||||||
+43
-7
@@ -319,20 +319,56 @@ select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Table ---------- */
|
/* ---------- Table ---------- */
|
||||||
.table-wrap { overflow-x: auto; }
|
/* Bounded height so the table body scrolls (both axes) while the header row
|
||||||
|
stays put. */
|
||||||
|
.table-wrap { overflow: auto; max-height: 70vh; }
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
thead th {
|
.table-wrap thead th {
|
||||||
|
position: sticky; top: 0; z-index: 2;
|
||||||
|
background: var(--surface);
|
||||||
text-align: left; font-size: 11.5px; letter-spacing: .05em;
|
text-align: left; font-size: 11.5px; letter-spacing: .05em;
|
||||||
text-transform: uppercase; color: var(--muted);
|
text-transform: uppercase; color: var(--muted);
|
||||||
padding: 10px 12px; border-bottom: 1px solid var(--border);
|
padding: 10px 12px; box-shadow: inset 0 -1px 0 var(--border);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
tbody td { padding: 11px 12px; border-bottom: 1px solid var(--surface-2); }
|
.table-wrap tbody td { padding: 11px 12px; border-bottom: 1px solid var(--surface-2); }
|
||||||
tbody tr:last-child td { border-bottom: none; }
|
.table-wrap tbody tr:last-child td { border-bottom: none; }
|
||||||
tbody tr:hover td { background: var(--surface-2); }
|
.table-wrap tbody tr:hover td { background: var(--surface-2); }
|
||||||
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
.table-wrap th.num, .table-wrap td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
tr.total td { font-weight: 700; background: var(--accent-soft); }
|
tr.total td { font-weight: 700; background: var(--accent-soft); }
|
||||||
tr.total:hover td { background: var(--accent-soft); }
|
tr.total:hover td { background: var(--accent-soft); }
|
||||||
|
tr.summary td { font-weight: 700; background: var(--accent-soft); }
|
||||||
|
tr.summary:hover td { background: var(--accent-soft); }
|
||||||
|
|
||||||
|
/* ---------- PivotTable.js experiment ---------- */
|
||||||
|
#pivot { overflow-x: auto; }
|
||||||
|
.panel-head-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
/* ---------- Per-dashboard table filters ---------- */
|
||||||
|
.table-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.table-filters input {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
min-width: 150px;
|
||||||
|
transition: border-color .15s, box-shadow .15s, background .15s;
|
||||||
|
}
|
||||||
|
.table-filters input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
display: inline-block; padding: 2px 9px; border-radius: 999px;
|
display: inline-block; padding: 2px 9px; border-radius: 999px;
|
||||||
|
|||||||
Reference in new issue
Block a user