diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..91cd88c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +data/ +.env +.git/ +*.md +tests/ +uploads/ diff --git a/Changes.md b/Changes.md index ceed6e6..77a48a4 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,51 @@ --- +## 2026-03-30 – KI-gestützte Belegerkennung (experimentell) + 30 % Eigenanteil Telefon/Internet + +### Neu +- Eigenanteil von 30 % bei Buchungen auf Telefon- und Internetkosten-Konten (4920/4925) wählbar +- Experimentelle KI-Beleganalyse: erkennt Buchungstyp, Betrag, Konto und Lieferant aus PDF/Bild-Belegen + - Setzt lokales LLM (z. B. Ollama/Mistral) oder Remote-API-Schlüssel voraus + - Hoher Ressourcenbedarf bei lokaler Ausführung; Ergebnisqualität mit Mistral bisher mäßig + - Getestet mit `ollama run mistral` + +### Betrifft +- `src/Services/KontierungsvorschlagService.php` +- `src/Services/LlmService.php` +- `src/Views/belege/detail.php`, `list.php` +- `Dockerfile`, `docker-compose.yml` + +--- + +## 2026-03-30 – Fix: Lieferant-Eingabe in Metadaten-Leiste speichert korrekt + +### Geändert +- `supplier-persistent-input`: Nach erfolgreichem AJAX-Speichern wird das Texteingabefeld durch den Inline-Trigger-Span ersetzt – visuelle Bestätigung, dass der Lieferant gespeichert wurde +- Fehlerbehandlung (`try/catch`) hinzugefügt: bei fehlgeschlagenem Speichern bleibt das Feld editierbar +- Das versteckte Metaformular (`supplier_name_input`) wird nach AJAX-Speichern synchronisiert, damit ein späteres Absenden des Metaformulars den Lieferanten nicht überschreibt + +### Betrifft +- `src/Views/belege/detail.php` + +--- + +## 2026-03-30 – Lieferant inline editierbar + KI-Lieferantenerkennung + +### Geändert +- Lieferant in der Beleg-Info-Bar ist jetzt direkt inline editierbar (Klick auf ✎), wie Belegdatum und Rechnungsnr. +- KI-Kontierungsvorschlag extrahiert nun auch den Lieferantennamen aus dem Beleg +- Nach KI-Analyse: bekannter Lieferant wird automatisch eingetragen; unbekannter Lieferant öffnet das Lieferantenfeld sofort mit Fokus +- KI-Badge zeigt Lieferantenname an, unbekannte Lieferanten werden als „(neu – bitte prüfen)" markiert +- Validierung in `KontierungsvorschlagService` prüft `zahlungskonto_id` und `kategorie_konto_id` jetzt gegen die korrekte Kontenart statt gegen alle Konten + +### Betrifft +- `src/Services/KontierungsvorschlagService.php` +- `src/Views/belege/detail.php` +- `public/static/js/app.js` + +--- + ## 2026-03-29 – EÜR: Spaltenauswahl nach Kontenart statt Buchungstyp ### Geändert diff --git a/Dockerfile b/Dockerfile index 547bf72..ef4e779 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y \ unzip \ curl \ poppler-utils \ + tesseract-ocr \ + tesseract-ocr-deu \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install pdo pdo_mysql zip gd \ && apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index 89f3274..b00f203 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,34 @@ Beim ersten Aufruf erscheint die **Ersteinrichtung**: dort den ersten Benutzer-A Weitere Benutzer können nach dem Login unter **Benutzer** in der Seitenleiste verwaltet werden. -#### 5. Firmendaten hinterlegen +#### 5. KI-Beleganalyse einrichten (optional) + +Die KI-gestützte Belegerkennung ist standardmäßig deaktiviert. Sie kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden, sobald ein LLM erreichbar ist. + +**Option A – Lokales Ollama** (kein API-Schlüssel nötig, aber hoher Ressourcenbedarf): + +```bash +ollama pull mistral # oder llama3.2 etc. +``` + +In `.env` ist keine weitere Konfiguration nötig – die App erkennt Ollama auf `localhost:11434` automatisch. Modell und URL können optional überschrieben werden: + +```dotenv +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=mistral +``` + +**Option B – Remote-API** (OpenAI-kompatibel, z. B. OpenAI, OpenRouter, LM Studio): + +```dotenv +LLM_API_URL=https://api.openai.com/v1 +LLM_API_KEY=sk-... +LLM_MODEL=gpt-4o-mini +``` + +> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Tests mit `mistral` (lokal via Ollama) lieferten bisher mäßige Ergebnisse. + +#### 6. Firmendaten hinterlegen Nach dem ersten Login unter **Einstellungen** Firmendaten, Logo und ELSTER-Steuernummer eintragen. Diese Angaben erscheinen auf Ausgangsrechnungen und im EÜR-Export. diff --git a/docker-compose.yml b/docker-compose.yml index 9bb0261..609e4fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,10 @@ services: DB_USER: steuerkram DB_PASS: steuerkram APP_SECRET: change-this-in-production + OLLAMA_URL: ${OLLAMA_URL:-http://host-gateway:11434} + OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3.2} + extra_hosts: + - "host-gateway:host-gateway" depends_on: db: condition: service_healthy diff --git a/migrations/012_konten_1800_8922.sql b/migrations/012_konten_1800_8922.sql new file mode 100644 index 0000000..4de3fcf --- /dev/null +++ b/migrations/012_konten_1800_8922.sql @@ -0,0 +1,10 @@ +-- Konto 1800: Bezeichnung korrigieren +UPDATE accounts SET bezeichnung = 'Privatentnahmen' WHERE kontonummer = '1800'; + +-- Konto 8922: Eigenverbrauch 19 % USt (neu) +INSERT INTO accounts (kontonummer, bezeichnung, kontenart, aktiv) +VALUES ('8922', 'Verwendung von Gegenständen für Zwecke außerhalb des Unternehmens 19 % USt', 'Betriebseinnahme', 1) +ON DUPLICATE KEY UPDATE + bezeichnung = VALUES(bezeichnung), + kontenart = VALUES(kontenart), + aktiv = VALUES(aktiv); diff --git a/public/static/css/app.css b/public/static/css/app.css index 27a1660..a28181f 100644 --- a/public/static/css/app.css +++ b/public/static/css/app.css @@ -346,7 +346,7 @@ td { padding: .65rem 1rem; vertical-align: middle; } /* ── Beleg Detail (Split-Pane) ───────────────────────────────── */ .beleg-layout { display: grid; - grid-template-columns: 1fr 420px; + grid-template-columns: 1fr 520px; gap: 1.5rem; align-items: start; } @@ -362,7 +362,7 @@ td { padding: .65rem 1rem; vertical-align: middle; } .beleg-preview iframe, .beleg-preview img { width: 100%; - min-height: 700px; + min-height: 1165px; border: none; display: block; } diff --git a/public/static/js/app.js b/public/static/js/app.js index 42dcf92..ad8e560 100644 --- a/public/static/js/app.js +++ b/public/static/js/app.js @@ -271,6 +271,10 @@ document.addEventListener('click', (e) => { input.type = type; input.value = rawValue; input.style.cssText = 'font-size:inherit;padding:2px 5px;border:1px solid var(--color-border);border-radius:var(--radius);background:#fff;min-width:' + (type === 'date' ? '140px' : '180px'); + if (field === 'supplier_name') { + input.setAttribute('list', 'supplier-datalist'); + input.setAttribute('autocomplete', 'off'); + } const originalHTML = trigger.innerHTML; trigger.innerHTML = ''; @@ -285,10 +289,11 @@ document.addEventListener('click', (e) => { const newValue = input.value.trim(); const body = new URLSearchParams({ - rechnungs_nr: field === 'rechnungs_nr' ? newValue : bar.dataset.rechnungsNr || '', + rechnungs_nr: field === 'rechnungs_nr' ? newValue : bar.dataset.rechnungsNr || '', belegtyp: bar.dataset.belegtyp || '', - beleg_datum: field === 'beleg_datum' ? newValue : bar.dataset.belegDatum || '', + beleg_datum: field === 'beleg_datum' ? newValue : bar.dataset.belegDatum || '', betrag_brutto: bar.dataset.betragBrutto || '', + supplier_name: field === 'supplier_name' ? newValue : bar.dataset.supplierName || '', }); try { @@ -309,6 +314,11 @@ document.addEventListener('click', (e) => { } else { trigger.innerHTML = '– setzen –'; } + } else if (field === 'supplier_name') { + bar.dataset.supplierName = newValue; + trigger.dataset.raw = newValue; + trigger.textContent = newValue || ''; + if (!newValue) trigger.innerHTML = '– setzen –'; } else { bar.dataset.rechnungsNr = newValue; trigger.dataset.raw = newValue; @@ -353,6 +363,7 @@ document.addEventListener('keydown', (e) => { // ── Flash Message Auto-dismiss ──────────────────────────────── document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.flash').forEach(el => { + if (el.closest('#ki-badge')) return; // KI-Badge nie auto-dismissen setTimeout(() => { el.style.transition = 'opacity .4s'; el.style.opacity = '0'; diff --git a/src/Services/KontierungsvorschlagService.php b/src/Services/KontierungsvorschlagService.php index 1e8c564..b5c9e87 100644 --- a/src/Services/KontierungsvorschlagService.php +++ b/src/Services/KontierungsvorschlagService.php @@ -6,6 +6,7 @@ namespace Steuerkram\Services; use Steuerkram\Models\Account; use Steuerkram\Models\Setting; +use Steuerkram\Models\Supplier; class KontierungsvorschlagService { @@ -28,28 +29,26 @@ class KontierungsvorschlagService if (!file_exists($absPath)) return null; // Inhalt extrahieren - $base64Image = null; $mime = strtolower($beleg['mime_type'] ?? ''); if ($mime === 'application/pdf') { $text = $this->extractPdfText($absPath); } elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) { - $base64Image = base64_encode(file_get_contents($absPath)); - $text = null; + $text = $this->extractImageText($absPath); } else { return null; } - // Konten laden + // Konten laden (max. 50 relevante Konten für kurzen Prompt) $allAccounts = Account::all(['aktiv' => 1]); - $kategorieKonten = array_filter( + $kategorieKonten = array_slice(array_values(array_filter( $allAccounts, fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true) - ); - $zahlungsKonten = array_filter( + )), 0, 40); + $zahlungsKonten = array_slice(array_values(array_filter( $allAccounts, fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true) - ); + )), 0, 10); // Kontenliste für Prompt aufbauen $kategorieList = implode("\n", array_map( @@ -61,20 +60,26 @@ class KontierungsvorschlagService $zahlungsKonten )); - $beleginhalt = $text !== null && trim($text) !== '' - ? "Belegtext:\n" . mb_substr(trim($text), 0, 3000) - : '[Bilddatei – Inhalt wird direkt analysiert]'; + $beleginhalt = trim($text) !== '' + ? "Belegtext:\n" . mb_substr(trim($text), 0, 2000) + : '[Kein Text extrahierbar]'; + + $settings = Setting::get(); + $eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? '')); + $eigenerHinweis = $eigenerName !== '' + ? "\n- supplier_name: NICHT \"{$eigenerName}\" (das ist der Rechnungsempfaenger, nicht der Lieferant). Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar." + : "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."; $prompt = <<llm->generate($prompt, $base64Image); + $response = $this->llm->generate($prompt); if ($response === '') return null; return $this->parseAndValidate($response, $allAccounts); @@ -99,6 +104,21 @@ PROMPT; return $returnCode === 0 ? implode("\n", $output) : ''; } + private function extractImageText(string $path): string + { + $tmpBase = sys_get_temp_dir() . '/ocr_' . uniqid(); + $safe = escapeshellarg($path); + $safeTmp = escapeshellarg($tmpBase); + exec("tesseract {$safe} {$safeTmp} -l deu+eng quiet 2>/dev/null", $out, $rc); + $txtFile = $tmpBase . '.txt'; + if ($rc === 0 && file_exists($txtFile)) { + $text = file_get_contents($txtFile); + unlink($txtFile); + return $text ?: ''; + } + return ''; + } + private function parseAndValidate(string $response, array $accounts): ?array { // JSON aus Antwort extrahieren (Modell könnte Prosa voranstellen) @@ -119,11 +139,25 @@ PROMPT; $data['ust_satz'] = in_array((int)$data['ust_satz'], [0, 7, 19], true) ? (int)$data['ust_satz'] : 0; $data['betrag_netto'] = round((float)$data['betrag_netto'], 2); $data['buchungstext'] = (string)($data['buchungstext'] ?? ''); + $data['supplier_name'] = trim((string)($data['supplier_name'] ?? '')); - // IDs gegen Kontenliste validieren - $accountIds = array_column($accounts, 'id'); - if (!in_array($data['kategorie_konto_id'], $accountIds, false)) return null; - if (!in_array($data['zahlungskonto_id'], $accountIds, false)) return null; + // IDs gegen Kontenliste validieren (mit Kontenartenprüfung) + $kategorieIds = array_column(array_filter($accounts, + fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true) + ), 'id'); + $zahlungsIds = array_column(array_filter($accounts, + fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true) + ), 'id'); + if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) return null; + if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) return null; + + // Lieferant gegen vorhandene Suppliers prüfen + if ($data['supplier_name'] !== '') { + $existing = Supplier::findByName($data['supplier_name']); + $data['supplier_found'] = $existing !== false; + } else { + $data['supplier_found'] = null; + } return $data; } diff --git a/src/Services/LlmService.php b/src/Services/LlmService.php index 99ed773..eb34f28 100644 --- a/src/Services/LlmService.php +++ b/src/Services/LlmService.php @@ -39,11 +39,11 @@ class LlmService }; } - public function generate(string $prompt, ?string $base64Image = null): string + public function generate(string $prompt): string { return match($this->mode) { - 'ollama' => $this->callOllama($prompt, $base64Image), - 'openai' => $this->callOpenAi($prompt, $base64Image), + 'ollama' => $this->callOllama($prompt), + 'openai' => $this->callOpenAi($prompt), default => '', }; } @@ -93,22 +93,19 @@ class LlmService return $err === 0 && $code >= 200 && $code < 300; } - private function callOllama(string $prompt, ?string $base64Image): string + private function callOllama(string $prompt): string { $payload = [ 'model' => $this->model, 'prompt' => $prompt, 'stream' => false, ]; - if ($base64Image !== null) { - $payload['images'] = [$base64Image]; - } $ch = curl_init($this->url . '/api/generate'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 120, + CURLOPT_TIMEOUT => 300, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode($payload), ]); @@ -120,27 +117,18 @@ class LlmService return json_decode($response, true)['response'] ?? ''; } - private function callOpenAi(string $prompt, ?string $base64Image): string + private function callOpenAi(string $prompt): string { - $content = []; - if ($base64Image !== null) { - $content[] = [ - 'type' => 'image_url', - 'image_url' => ['url' => 'data:image/jpeg;base64,' . $base64Image], - ]; - } - $content[] = ['type' => 'text', 'text' => $prompt]; - $payload = [ 'model' => $this->model, - 'messages' => [['role' => 'user', 'content' => $content]], + 'messages' => [['role' => 'user', 'content' => $prompt]], ]; $ch = curl_init($this->url . '/chat/completions'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 120, + CURLOPT_TIMEOUT => 300, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $this->apiKey, diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index 610de12..afc9d49 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -14,7 +14,8 @@ $suppliers = $suppliers ?? []; data-belegtyp="" data-beleg-datum="" data-rechnungs-nr="" - data-betrag-brutto=""> + data-betrag-brutto="" + data-supplier-name="">
Belegnr.
@@ -25,8 +26,23 @@ $suppliers = $suppliers ?? [];
- Lieferant
- –' ?> + Lieferant✎' : '' ?>
+ + + + + + + + –' ?> +
Belegdatum✎' : '' ?>
@@ -263,9 +279,13 @@ $suppliers = $suppliers ?? []; $beleg['belegtyp'] === 'Ausgangsrechnung' ? 'Einnahme' : 'Ausgabe' ); - // Konten nach Art gruppieren + // Konten nach Art gruppieren (Zahlungskonto nur 1200–1299) $kontenGruppen = []; foreach ($accounts as $acc) { + if ($acc['kontenart'] === 'Zahlungskonto') { + $nr = (int)$acc['kontonummer']; + if ($nr < 1200 || $nr > 1299) continue; + } $kontenGruppen[$acc['kontenart']][] = $acc; } ?> @@ -416,11 +436,11 @@ $suppliers = $suppliers ?? [];
- + +
@@ -475,7 +495,7 @@ function updateEigenanteilState() { group.style.opacity = aktiv ? '1' : '.35'; group.style.pointerEvents = aktiv ? '' : 'none'; - group.querySelector('label').style.cursor = aktiv ? 'pointer' : 'not-allowed'; + group.style.cursor = aktiv ? 'pointer' : 'not-allowed'; cb.disabled = !aktiv; if (!aktiv) cb.checked = false; } @@ -486,6 +506,10 @@ document.addEventListener('DOMContentLoaded', function() { if (checked) filterKategorieKonten(checked.value); updateSteuerartVisibility(); updateEigenanteilState(); + + // Leeres Lieferantenfeld fokussieren wenn kein KI-Block aktiv + const sup = document.getElementById('supplier-persistent-input'); + if (sup && !document.getElementById('ki-badge')) sup.focus(); }); function updateSteuerartVisibility() { @@ -509,26 +533,70 @@ function updateSteuerartVisibility() { const _belegId = ; const _kiVorschlag = ; +function _selectById(selectEl, id) { + if (!selectEl) return; + const str = String(id); + for (const opt of selectEl.options) { + opt.selected = (opt.value === str); + } +} + function _applyKiVorschlag(v) { const btRadio = document.querySelector('input[name="buchungstyp"][value="' + v.buchungstyp + '"]'); if (btRadio) { btRadio.checked = true; filterKategorieKonten(v.buchungstyp); } const ustEl = document.getElementById('ust_satz_select'); if (ustEl) { ustEl.value = String(v.ust_satz); updateSteuerartVisibility(); } - const katEl = document.getElementById('kategorie_konto_id'); - if (katEl) { katEl.value = String(v.kategorie_konto_id); updateEigenanteilState(); } - const zhlEl = document.getElementById('zahlungskonto_id'); - if (zhlEl) zhlEl.value = String(v.zahlungskonto_id); + const katEl2 = document.getElementById('kategorie_konto_id'); + _selectById(katEl2, v.kategorie_konto_id); + if (katEl2) transferKontoNr(katEl2); + updateEigenanteilState(); + const zhlEl2 = document.getElementById('zahlungskonto_id'); + _selectById(zhlEl2, v.zahlungskonto_id); + if (zhlEl2) transferKontoNr(zhlEl2); const netEl = document.querySelector('input[name="betrag_netto"]'); if (netEl && v.betrag_netto) netEl.value = v.betrag_netto; const txtEl = document.querySelector('input[name="buchungstext"]'); if (txtEl && v.buchungstext) txtEl.value = v.buchungstext; + + // Lieferant aus KI-Vorschlag übernehmen + if (v.supplier_name) { + const bar = document.querySelector('[data-beleg-id]'); + const persistentInput = document.getElementById('supplier-persistent-input'); + const trigger = document.getElementById('supplier-inline-trigger'); + if (persistentInput) { + persistentInput.value = v.supplier_name; + if (bar) bar.dataset.supplierName = v.supplier_name; + if (v.supplier_found === false) persistentInput.focus(); + } else if (trigger) { + trigger.dataset.raw = v.supplier_name; + if (bar) bar.dataset.supplierName = v.supplier_name; + trigger.textContent = v.supplier_name; + if (v.supplier_found === false && !trigger.querySelector('input')) { + trigger.click(); + } + } + } } -function _showKiBadgeDone() { +function _showKiBadgeDone(v) { + 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; + const zhlOpt = zhlEl ? zhlEl.querySelector('option[value="' + v.zahlungskonto_id + '"]') : null; + const katTxt = katOpt ? katOpt.text : v.kategorie_konto_id; + const zhlTxt = zhlOpt ? zhlOpt.text : v.zahlungskonto_id; + const supTxt = v.supplier_name + ? ' · Lieferant: ' + v.supplier_name + (v.supplier_found === false ? ' (neu – bitte prüfen)' : '') + : ''; + const details = + (v.buchungstyp ? v.buchungstyp + ' · ' : '') + + 'USt ' + v.ust_satz + ' % · ' + + 'Kategorie: ' + katTxt + ' · ' + + 'Zahlung: ' + zhlTxt + supTxt; document.getElementById('ki-badge').innerHTML = - '
' + - 'KI-Kontierungsvorschlag – Formular vorausgefüllt. Bitte prüfen und ggf. anpassen.' + - '' + + '
' + + '
KI-Vorschlag: ' + details + '
' + + '' + '
'; } @@ -549,7 +617,7 @@ async function _startKiAnalyse() { 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(); } + if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); } else _showKiBadgeError(); } catch (e) { _showKiBadgeError(); @@ -559,13 +627,13 @@ async function _startKiAnalyse() { document.addEventListener('DOMContentLoaded', function () { if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) { _applyKiVorschlag(_kiVorschlag); - _showKiBadgeDone(); + _showKiBadgeDone(_kiVorschlag); } else { _showKiBadgeLoading(); fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'}) .then(r => r.json()) .then(v => { - if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(); } + if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); } else _showKiBadgeError(); }) .catch(() => _showKiBadgeError()); @@ -573,3 +641,54 @@ document.addEventListener('DOMContentLoaded', function () { }); + + + + diff --git a/src/Views/belege/list.php b/src/Views/belege/list.php index cc1693b..b7e5663 100644 --- a/src/Views/belege/list.php +++ b/src/Views/belege/list.php @@ -1,5 +1,94 @@ View::h($v); ?> + +
+ +
📎
+
Beleg hierher ziehen oder klicken
+
PDF, PNG, JPEG, TIFF, XML · max. 20 MB
+
+
+ + +