Mir geht es wie Olaf.
Ich habe in meinem Beispiel natürlich Kategoriebezeichnungen aus meiner Demodatenbank genommen.
Wenn du diese Kategoriebezeichnungen jetzt gedanklich durch deine "Datev-Kontonummern" ersetzt, bekommst du doch das gewünschte heraus. Oder?
Lade dir die aktuellste Version des Reports-Plugin (1.3.1) herunter und füge folgendes als Report hinzu.
Code
{% set startdatum = "2026-01-08" %}
{% set enddatum = "2027-06-30" %}
{# Leer = alle aktiven Konten; sonst IBANs, z. B. ["DE123...", "DE456..."] #}
{% set konto_ibans = [] %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Umsatzkategorien</title>
<style>
:root {
color: #202124;
background: #fff;
font: 14px/1.35 Arial, sans-serif;
}
body {
margin: 0;
padding: 34px 42px;
}
h1 {
margin: 0;
text-align: center;
font-size: 23px;
font-weight: 600;
}
.subtitle {
margin: 5px 0 26px;
text-align: center;
color: #4b5563;
}
table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
th {
padding: 6px 8px;
border-bottom: 2px solid #9ca3af;
text-align: left;
font-weight: 600;
}
td {
padding: 3px 8px;
border-bottom: 1px solid #e5e7eb;
}
tbody tr.root td {
padding-top: 8px;
font-weight: 600;
}
.category {
width: 25%;
padding-left: calc(8px + var(--depth) * 22px);
}
.note {
width: 30%;
color: #4b5563;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
tbody tr.root .note {
font-weight: 400;
}
.amount {
width: 15%;
text-align: right;
white-space: nowrap;
}
.empty,
.error {
padding: 30px 8px;
text-align: center;
color: #6b7280;
}
.source {
display: none;
}
.attribution {
margin: 24px 0 0;
color: #6b7280;
font-size: 12px;
text-align: right;
}
.attribution a {
color: inherit;
}
@media print {
body { padding: 18mm 14mm; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
}
</style>
</head>
<body>
<h1>Umsatzkategorien</h1>
<p class="subtitle">
Zeitraum: <span id="period"></span>, <span id="accounts-label"></span>
</p>
<table aria-label="Summen nach Umsatzkategorie">
<thead>
<tr>
<th>Kategorie</th>
<th>Notiz</th>
<th class="amount">Einnahmen</th>
<th class="amount">Ausgaben</th>
<th class="amount">Betrag</th>
</tr>
</thead>
<tbody id="result"></tbody>
</table>
<p class="attribution">
Urheber: <a href="https://littleyoda.github.io/" target="_blank"
rel="noopener noreferrer">littleyoda</a>
</p>
<div class="source" id="configuration"
data-start="{{ startdatum }}" data-end="{{ enddatum }}">
{% for iban in konto_ibans %}<i class="selected-iban">{{ iban }}</i>{% endfor %}
</div>
<div class="source" id="accounts">
{% for konto in konten.alle %}
<i class="account">
<b class="account-id">{{ konto.id }}</b>
<span class="account-name">{{ konto.name }}</span>
<span class="account-iban">{{ konto.iban }}</span>
<span class="account-active">{{ konto.aktiv }}</span>
</i>
{% endfor %}
</div>
<div class="source" id="categories">
{% for kategorie in kategorien %}
<i class="catalog-category">
<b class="category-id">{{ kategorie.id }}</b>
<span class="category-name">{{ kategorie.name }}</span>
<span class="category-note">{{ kategorie.notiz }}</span>
<span class="category-skipped">{{ kategorie.skipReportsEffective }}</span>
<span class="category-order">{{ loop.index0 }}</span>
</i>
{% endfor %}
</div>
<div class="source" id="transactions">
{% for umsatz in umsaetze.alle %}
<div class="transaction">
<i class="date">{{ umsatz.datum }}</i>
<i class="account-id">{{ umsatz.konto.id }}</i>
<i class="value">{{ umsatz.betrag }}</i>
<div class="path">
{% for kategorie in umsatz.kategoriePfad %}
<i class="path-part"><b>{{ kategorie.id }}</b><span>{{ kategorie.name }}</span></i>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<script>
(() => {
const config = document.getElementById("configuration");
const start = config.dataset.start;
const end = config.dataset.end;
const normalizeIban = value => value.replace(/\s+/g, "").toUpperCase();
const explicitlySelected = [...config.querySelectorAll(".selected-iban")]
.map(element => normalizeIban(element.textContent));
const accounts = [...document.querySelectorAll("#accounts .account")].map(element => ({
id: element.querySelector(".account-id").textContent.trim(),
name: element.querySelector(".account-name").textContent.trim(),
iban: normalizeIban(element.querySelector(".account-iban").textContent),
active: element.querySelector(".account-active").textContent.trim() === "true"
}));
const selectedAccounts = explicitlySelected.length
? accounts.filter(account => explicitlySelected.includes(account.iban))
: accounts.filter(account => account.active);
const selected = new Set(selectedAccounts.map(account => account.id));
const categories = new Map([...document.querySelectorAll("#categories .catalog-category")]
.map(element => {
const text = selector => element.querySelector(selector).textContent.trim();
return [text(".category-id"), {
name: text(".category-name"),
note: text(".category-note"),
skipped: text(".category-skipped") === "true",
order: Number(text(".category-order"))
}];
}));
const roots = new Map();
function child(parent, id, name) {
const key = id || `name:${name}`;
if (!parent.children.has(key)) {
const metadata = categories.get(id);
parent.children.set(key, {
name: metadata?.name || name,
note: metadata?.note || "",
order: metadata?.order ?? Number.MAX_SAFE_INTEGER,
income: 0,
expenses: 0,
children: new Map()
});
}
return parent.children.get(key);
}
function add(node, value) {
if (value >= 0) node.income += value;
else node.expenses += value;
}
const uncategorized = {
name: "Nicht zugeordnet", note: "", order: -1,
income: 0, expenses: 0, children: new Map()
};
for (const transaction of document.querySelectorAll("#transactions .transaction")) {
const date = transaction.querySelector(".date").textContent.trim();
const accountId = transaction.querySelector(".account-id").textContent.trim();
if (date < start || date > end || !selected.has(accountId)) continue;
const value = Number(transaction.querySelector(".value").textContent.trim());
if (!Number.isFinite(value)) continue;
const path = [...transaction.querySelectorAll(".path-part")];
if (!path.length) {
add(uncategorized, value);
continue;
}
const pathIds = path.map(part => part.querySelector("b").textContent.trim());
if (pathIds.some(id => categories.get(id)?.skipped)) continue;
let parent = { children: roots };
for (const part of path) {
const node = child(parent,
part.querySelector("b").textContent.trim(),
part.querySelector("span").textContent.trim());
add(node, value);
parent = node;
}
}
const money = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
const date = value => {
const [year, month, day] = value.split("-");
return `${day}.${month}.${year}`;
};
const rows = [];
function appendRow(node, depth) {
const row = document.createElement("tr");
if (depth === 0) row.className = "root";
const category = document.createElement("td");
category.className = "category";
category.style.setProperty("--depth", depth);
category.textContent = node.name;
row.append(category);
const note = document.createElement("td");
note.className = "note";
note.textContent = node.note;
row.append(note);
for (const value of [node.income, node.expenses, node.income + node.expenses]) {
const cell = document.createElement("td");
cell.className = "amount";
cell.textContent = money.format(value);
row.append(cell);
}
rows.push(row);
[...node.children.values()]
.sort((left, right) => left.order - right.order
|| left.name.localeCompare(right.name, "de", { numeric: true }))
.forEach(childNode => appendRow(childNode, depth + 1));
}
if (uncategorized.income || uncategorized.expenses) appendRow(uncategorized, 0);
[...roots.values()]
.sort((left, right) => left.order - right.order
|| left.name.localeCompare(right.name, "de", { numeric: true }))
.forEach(node => appendRow(node, 0));
const result = document.getElementById("result");
if (rows.length) result.append(...rows);
else result.innerHTML = '<tr><td class="empty" colspan="5">Keine Umsätze für die Auswahl gefunden.</td></tr>';
document.getElementById("period").textContent = `${date(start)} - ${date(end)}`;
if (!explicitlySelected.length) {
document.getElementById("accounts-label").textContent = "alle aktiven Konten";
} else {
const labels = selectedAccounts.map(account => account.name);
const known = new Set(selectedAccounts.map(account => account.iban));
explicitlySelected
.filter(iban => !known.has(iban))
.forEach(iban => labels.push(`IBAN ${iban} (nicht gefunden)`));
document.getElementById("accounts-label").textContent = labels.join(", ");
}
})();
</script>
</body>
</html>