Refactor: KI-Analyse nicht mehr automatisch beim Öffnen eines Belegs
This commit is contained in:
parent
b51124fd0a
commit
b9b88defb8
10 changed files with 253 additions and 35 deletions
|
|
@ -11,6 +11,9 @@ DB_PASS=steuerkram
|
|||
# OLLAMA_URL=http://localhost:11434
|
||||
# OLLAMA_MODEL=llama3.2
|
||||
#
|
||||
# Vision-Modell für Beleglesung (multimodales Ollama-Modell, benötigt OLLAMA_URL)
|
||||
# VISION_MODEL=qwen2.5vl:latest
|
||||
#
|
||||
# Option B: Remote-LLM per OpenAI-kompatibler API (z.B. OpenAI, OpenRouter, LM Studio)
|
||||
# LLM_API_URL=https://api.openai.com/v1
|
||||
# LLM_API_KEY=sk-...
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-04-15 – KI-Erkennung: automatischer Aufruf aus Belegansicht entfernt
|
||||
|
||||
### Geändert
|
||||
- `src/Views/belege/detail.php`: KI-Analyse wird nicht mehr automatisch beim Öffnen eines Belegs ausgelöst; stattdessen wird ein Hinweis "KI-Analyse wird im Hintergrund durchgeführt …" mit einem "Jetzt generieren"-Button angezeigt, wenn noch kein Vorschlag vorhanden ist
|
||||
- Hintergrund: Die Analyse läuft bereits beim Nextcloud-Import (`ki-batch.php`); ein doppelter Aufruf aus der UI war überflüssig
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt
|
||||
|
||||
### Geändert
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ Belege können automatisch aus einem Nextcloud-Ordner via WebDAV importiert werd
|
|||
|
||||
Die Zugangsdaten werden in der Steuerkram-UI gepflegt: **Einstellungen → Nextcloud-Belegimport**.
|
||||
|
||||
Empfehlung: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter).
|
||||
Hinweis: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter).
|
||||
|
||||
### Manueller Aufruf
|
||||
|
||||
|
|
|
|||
104
scripts/ki-batch.php
Normal file
104
scripts/ki-batch.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* KI-Kontierungsvorschlag – Batch-Verarbeitung (CLI-Einstiegspunkt).
|
||||
*
|
||||
* Verarbeitet EINEN ausstehenden Beleg pro Aufruf (ki_vorschlag IS NULL, locked = 0).
|
||||
* Verhindert so, dass mehrere Vision-Model-Anfragen parallel an Ollama geschickt werden.
|
||||
*
|
||||
* Voraussetzung: ki_vorschlag_aktiv = 1 in den Einstellungen, Ollama erreichbar.
|
||||
*
|
||||
* Aufruf:
|
||||
* docker compose exec web php scripts/ki-batch.php
|
||||
*
|
||||
* Für optionalen eigenen Cronjob (manuelle Uploads ohne Nextcloud):
|
||||
* *\/5 * * * * docker compose -f /opt/steuerkram/docker-compose.yml exec -T web \
|
||||
* php scripts/ki-batch.php >> /var/log/steuerkram-ki.log 2>&1
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
// DB-Zugangsdaten aus .env laden (für CLI-Kontext ohne Apache-Umgebung)
|
||||
if (file_exists('.env')) {
|
||||
foreach (file('.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value, " \t\n\r\0\x0B\"'");
|
||||
if (!array_key_exists($key, $_ENV)) {
|
||||
$_ENV[$key] = $value;
|
||||
putenv("{$key}={$value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require_once 'vendor/autoload.php';
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Database;
|
||||
use Steuerkram\Models\Beleg;
|
||||
use Steuerkram\Models\Setting;
|
||||
use Steuerkram\Services\KontierungsvorschlagService;
|
||||
|
||||
Config::load();
|
||||
|
||||
$tag = '[ki-batch]';
|
||||
|
||||
// KI-Erkennung in den Einstellungen aktiv?
|
||||
$settings = Setting::get();
|
||||
if (empty($settings['ki_vorschlag_aktiv'])) {
|
||||
echo "{$tag} KI-Erkennung ist in den Einstellungen deaktiviert – nichts zu tun." . PHP_EOL;
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$svc = new KontierungsvorschlagService();
|
||||
|
||||
if (!$svc->isEnabled()) {
|
||||
echo "{$tag} LLM nicht erreichbar – Abbruch." . PHP_EOL;
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Optionale Beleg-ID als CLI-Argument: php ki-batch.php 123
|
||||
$argId = isset($argv[1]) && ctype_digit((string)$argv[1]) ? (int)$argv[1] : null;
|
||||
|
||||
// Beleg holen: wenn ID angegeben, diesen direkt verarbeiten; sonst ältesten ausstehenden
|
||||
$row = $argId !== null
|
||||
? Database::fetchOne('SELECT id FROM belege WHERE id = ? AND ki_vorschlag IS NULL AND locked = 0', [$argId])
|
||||
: Database::fetchOne('SELECT id FROM belege WHERE ki_vorschlag IS NULL AND locked = 0 ORDER BY id ASC LIMIT 1');
|
||||
|
||||
if (!$row) {
|
||||
echo "{$tag} Kein ausstehender Beleg gefunden." . PHP_EOL;
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$belegId = (int)$row['id'];
|
||||
$beleg = Beleg::find($belegId);
|
||||
|
||||
if (!$beleg) {
|
||||
echo "{$tag} Beleg #{$belegId} nicht ladbar – Abbruch." . PHP_EOL;
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "{$tag} Verarbeite Beleg #{$belegId} ({$beleg['beleg_nr']}) ..." . PHP_EOL;
|
||||
|
||||
$start = microtime(true);
|
||||
$result = $svc->generateForBeleg($beleg);
|
||||
|
||||
if ($result === null) {
|
||||
echo "{$tag} Beleg #{$belegId}: Kein Vorschlag generiert (nicht unterstützter Typ oder LLM-Fehler)." . PHP_EOL;
|
||||
// Leeres Array speichern damit der Beleg nicht erneut verarbeitet wird
|
||||
Beleg::saveKiVorschlag($belegId, []);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
Beleg::saveKiVorschlag($belegId, $result);
|
||||
|
||||
$elapsed = round(microtime(true) - $start, 1);
|
||||
$zeilen = count($result);
|
||||
echo "{$tag} Beleg #{$belegId}: {$zeilen} Buchungszeile(n) generiert in {$elapsed}s." . PHP_EOL;
|
||||
|
||||
exit(0);
|
||||
|
|
@ -41,6 +41,7 @@ if (file_exists('.env')) {
|
|||
require_once 'vendor/autoload.php';
|
||||
|
||||
use Steuerkram\Config;
|
||||
use Steuerkram\Models\Setting;
|
||||
use Steuerkram\Services\NextcloudImportService;
|
||||
|
||||
Config::load(); // Database verbindet sich lazy über Config::get()
|
||||
|
|
@ -64,4 +65,16 @@ echo sprintf(
|
|||
$result['errors'],
|
||||
);
|
||||
|
||||
// KI-Vorschlag für jeden neu importierten Beleg anstoßen (wenn KI-Erkennung aktiv)
|
||||
if (!empty($result['imported_ids'])) {
|
||||
$settings = Setting::get();
|
||||
if (!empty($settings['ki_vorschlag_aktiv'])) {
|
||||
$kiBatch = __DIR__ . '/ki-batch.php';
|
||||
echo '[nextcloud-import] Starte KI-Erkennung für ' . count($result['imported_ids']) . ' importierte(n) Beleg(e) ...' . PHP_EOL;
|
||||
foreach ($result['imported_ids'] as $belegId) {
|
||||
passthru('php ' . escapeshellarg($kiBatch) . ' ' . (int)$belegId . ' 2>&1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exit($result['errors'] > 0 ? 1 : 0);
|
||||
|
|
|
|||
|
|
@ -204,6 +204,8 @@ class BelegController
|
|||
$kiVorschlag = $beleg['ki_vorschlag'] !== null
|
||||
? json_decode($beleg['ki_vorschlag'], true)
|
||||
: null;
|
||||
// KI-Sektion anzeigen wenn Ergebnis vorhanden ODER LLM gerade verfügbar
|
||||
$kiSektion = $kiAktiv || $kiVorschlag !== null;
|
||||
|
||||
$view = new View();
|
||||
$view->render('layout/base.php', [
|
||||
|
|
@ -215,6 +217,7 @@ class BelegController
|
|||
'integrity' => $integrity,
|
||||
'accounts' => $accounts,
|
||||
'kiAktiv' => $kiAktiv,
|
||||
'kiSektion' => $kiSektion,
|
||||
'kiVorschlag' => $kiVorschlag,
|
||||
]),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class LlmService
|
|||
private ?string $mode = null; // 'ollama' | 'openai' | null
|
||||
private string $url = '';
|
||||
private string $model = '';
|
||||
private string $visionModel = '';
|
||||
private ?string $apiKey = null;
|
||||
|
||||
public function __construct()
|
||||
|
|
@ -30,6 +31,11 @@ class LlmService
|
|||
return $this->mode !== null;
|
||||
}
|
||||
|
||||
public function isVisionAvailable(): bool
|
||||
{
|
||||
return $this->mode === 'ollama';
|
||||
}
|
||||
|
||||
public function getProviderLabel(): ?string
|
||||
{
|
||||
return match($this->mode) {
|
||||
|
|
@ -48,6 +54,45 @@ class LlmService
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet Bilder (base64-kodiert) zusammen mit einem Prompt an das Vision-Modell.
|
||||
* Nur im Ollama-Modus verfügbar. Gibt '' zurück wenn Vision nicht verfügbar.
|
||||
*
|
||||
* @param string $prompt
|
||||
* @param string[] $base64Images Array von base64-kodierten Bildern (JPEG/PNG)
|
||||
*/
|
||||
public function generateVision(string $prompt, array $base64Images): string
|
||||
{
|
||||
if ($this->mode !== 'ollama' || empty($base64Images)) return '';
|
||||
|
||||
$payload = [
|
||||
'model' => $this->visionModel,
|
||||
'messages' => [[
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
'images' => $base64Images,
|
||||
]],
|
||||
'stream' => false,
|
||||
'options' => ['temperature' => 0.1],
|
||||
];
|
||||
|
||||
$ch = curl_init($this->url . '/api/chat');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err || !$response) return '';
|
||||
$data = json_decode($response, true);
|
||||
return $data['message']['content'] ?? '';
|
||||
}
|
||||
|
||||
private function detect(): void
|
||||
{
|
||||
$ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
|
||||
|
|
@ -58,6 +103,7 @@ class LlmService
|
|||
$this->mode = 'ollama';
|
||||
$this->url = rtrim($ollamaUrl, '/');
|
||||
$this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2');
|
||||
$this->visionModel = getenv('VISION_MODEL') ?: ($_ENV['VISION_MODEL'] ?? 'qwen2.5vl:latest');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class NextcloudImportService
|
|||
*/
|
||||
public function import(): array
|
||||
{
|
||||
$result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => []];
|
||||
$result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []];
|
||||
|
||||
if (!$this->baseUrl || !$this->user || !$this->pass) {
|
||||
$result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
|
||||
|
|
@ -262,7 +262,7 @@ class NextcloudImportService
|
|||
|
||||
// In Datenbank anlegen
|
||||
$today = date('Y-m-d');
|
||||
Beleg::create([
|
||||
$belegId = Beleg::create([
|
||||
'beleg_nr' => $belegNr,
|
||||
'filename' => $filename,
|
||||
'storage_path' => $storagePath,
|
||||
|
|
@ -280,6 +280,7 @@ class NextcloudImportService
|
|||
|
||||
$result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}";
|
||||
$result['imported']++;
|
||||
$result['imported_ids'][] = $belegId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ $suppliers = $suppliers ?? [];
|
|||
</div>
|
||||
|
||||
<!-- KI-Kontierungsvorschlag -->
|
||||
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
|
||||
<?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
|
||||
<div id="ki-badge" style="margin-bottom:1rem"></div>
|
||||
<?php endif; ?>
|
||||
|
||||
|
|
@ -630,9 +630,10 @@ function updateNettoAnzeige() {
|
|||
}
|
||||
</script>
|
||||
|
||||
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
|
||||
<?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
|
||||
<script>
|
||||
const _belegId = <?= (int)$beleg['id'] ?>;
|
||||
const _kiAktiv = <?= json_encode((bool)($kiAktiv ?? false)) ?>;
|
||||
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
|
||||
|
||||
function _selectById(selectEl, id) {
|
||||
|
|
@ -680,7 +681,9 @@ function _applyKiVorschlag(v) {
|
|||
}
|
||||
}
|
||||
|
||||
function _showKiBadgeDone(v) {
|
||||
function _showKiBadgeDone(arr) {
|
||||
// arr ist immer ein Array (bereits normalisiert)
|
||||
const v = arr[0];
|
||||
const katEl = document.getElementById('kategorie_konto_id');
|
||||
const zhlEl = document.getElementById('zahlungskonto_id');
|
||||
const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null;
|
||||
|
|
@ -690,18 +693,44 @@ function _showKiBadgeDone(v) {
|
|||
const supTxt = v.supplier_name
|
||||
? ' · Lieferant: ' + v.supplier_name + (v.supplier_found === false ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '')
|
||||
: '';
|
||||
const details =
|
||||
|
||||
// Erste Zeile (bereits ins Formular übertragen)
|
||||
let details =
|
||||
(v.buchungstyp ? v.buchungstyp + ' · ' : '') +
|
||||
'USt ' + v.ust_satz + ' % · ' +
|
||||
'Kategorie: ' + katTxt + ' · ' +
|
||||
'Zahlung: ' + zhlTxt + supTxt;
|
||||
|
||||
// Alle Buchungszeilen mit Beträgen anzeigen
|
||||
const fmt = n => parseFloat(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
let zeilen = '';
|
||||
arr.forEach((z, i) => {
|
||||
const netto = parseFloat(z.betrag_netto) || 0;
|
||||
const ust = Math.round(netto * z.ust_satz / 100 * 100) / 100;
|
||||
const brutto = Math.round((netto + ust) * 100) / 100;
|
||||
const hinweis = (i > 0) ? ' <span style="color:var(--color-muted);font-style:italic">(bitte als weitere Buchung erfassen)</span>' : '';
|
||||
zeilen += '<br><span style="font-size:.85em">Zeile ' + (i + 1) + ': '
|
||||
+ 'Netto ' + fmt(netto) + ' € | USt ' + z.ust_satz + '% ' + fmt(ust) + ' € | Brutto ' + fmt(brutto) + ' €'
|
||||
+ hinweis + '</span>';
|
||||
});
|
||||
|
||||
const neuBtn = _kiAktiv
|
||||
? '<button class="btn btn-secondary btn-sm" style="flex-shrink:0;margin-top:.1rem" onclick="_startKiAnalyse()">Neu generieren</button>'
|
||||
: '';
|
||||
document.getElementById('ki-badge').innerHTML =
|
||||
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center;gap:1rem">' +
|
||||
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span></div>' +
|
||||
'<button class="btn btn-secondary btn-sm" style="flex-shrink:0" onclick="_startKiAnalyse()">Neu generieren</button>' +
|
||||
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem">' +
|
||||
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span>' + zeilen + '</div>' +
|
||||
neuBtn +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function _showKiBadgeNoResult() {
|
||||
const neuBtn = _kiAktiv
|
||||
? ' <button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button>'
|
||||
: '';
|
||||
document.getElementById('ki-badge').innerHTML =
|
||||
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert.' + neuBtn + '</div>';
|
||||
}
|
||||
|
||||
function _showKiBadgeLoading() {
|
||||
document.getElementById('ki-badge').innerHTML =
|
||||
'<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>';
|
||||
|
|
@ -709,36 +738,47 @@ function _showKiBadgeLoading() {
|
|||
|
||||
function _showKiBadgeError() {
|
||||
document.getElementById('ki-badge').innerHTML =
|
||||
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert. ' +
|
||||
'<div class="flash flash-warning">Fehler bei der KI-Analyse. ' +
|
||||
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button></div>';
|
||||
}
|
||||
|
||||
// Normalisiert _kiVorschlag zu einem Array (Objekt oder Array aus DB möglich).
|
||||
// Gibt null zurück wenn kein Ergebnis vorhanden.
|
||||
// Gibt leeres Array [] zurück wenn verarbeitet aber kein Ergebnis erzeugt.
|
||||
function _normalizeKiArr(raw) {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const arr = Array.isArray(raw) ? raw : [raw];
|
||||
// Leeres Array = "verarbeitet, kein Ergebnis"
|
||||
if (arr.length === 0) return [];
|
||||
// Mindestens erstes Element muss gültig sein
|
||||
return (arr[0] && arr[0].kategorie_konto_id) ? arr : [];
|
||||
}
|
||||
|
||||
async function _startKiAnalyse() {
|
||||
await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'});
|
||||
_showKiBadgeLoading();
|
||||
try {
|
||||
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
|
||||
const v = await r.json();
|
||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
|
||||
else _showKiBadgeError();
|
||||
const raw = await r.json();
|
||||
const arr = _normalizeKiArr(raw);
|
||||
if (arr && arr.length > 0) { _applyKiVorschlag(arr[0]); _showKiBadgeDone(arr); }
|
||||
else _showKiBadgeNoResult();
|
||||
} catch (e) {
|
||||
_showKiBadgeError();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) {
|
||||
_applyKiVorschlag(_kiVorschlag);
|
||||
_showKiBadgeDone(_kiVorschlag);
|
||||
const arr = _normalizeKiArr(_kiVorschlag);
|
||||
if (arr === null) {
|
||||
// Noch nicht verarbeitet – nichts anzeigen
|
||||
} else if (arr.length === 0) {
|
||||
// Verarbeitet, aber kein Ergebnis
|
||||
_showKiBadgeNoResult();
|
||||
} else {
|
||||
_showKiBadgeLoading();
|
||||
fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(v => {
|
||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
|
||||
else _showKiBadgeError();
|
||||
})
|
||||
.catch(() => _showKiBadgeError());
|
||||
// Vorschlag vorhanden → ins Formular übernehmen und anzeigen
|
||||
_applyKiVorschlag(arr[0]);
|
||||
_showKiBadgeDone(arr);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string {
|
|||
}
|
||||
?>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></div>
|
||||
<div class="sidebar-brand"><a href="https://codeberg.org/fds2610/Steuerkram" target="_blank" rel="noopener noreferrer" style="display:inline-flex;align-items:center;color:inherit;text-decoration:none;"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></a></div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-label">Übersicht</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue