30% Eigenanteil für Telefon und Internet + Tests mit KI Beleg-Erkennung

This commit is contained in:
fds2610 2026-03-30 16:53:47 +02:00
parent b8ef04033c
commit 926d4a5ee0
12 changed files with 397 additions and 62 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
data/
.env
.git/
*.md
tests/
uploads/

View file

@ -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

View file

@ -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/*

View file

@ -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.

View file

@ -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

View file

@ -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);

View file

@ -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;
}

View file

@ -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 = '';
@ -289,6 +293,7 @@ document.addEventListener('click', (e) => {
belegtyp: bar.dataset.belegtyp || '',
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 = '<span class="text-muted">– setzen –</span>';
}
} else if (field === 'supplier_name') {
bar.dataset.supplierName = newValue;
trigger.dataset.raw = newValue;
trigger.textContent = newValue || '';
if (!newValue) trigger.innerHTML = '<span class="text-muted">– setzen –</span>';
} 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';

View file

@ -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 = <<<PROMPT
Du bist ein Buchhalter (SKR03-Kontenrahmen, Deutschland). Analysiere den folgenden Beleg und erstelle einen Kontierungsvorschlag.
Antworte AUSSCHLIESSLICH mit einem einzigen JSON-Objekt, ohne erklaerenden Text davor oder danach:
{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":33.64,"buchungstext":"Rechnung 2026-001"}
{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":33.64,"buchungstext":"Rechnung 2026-001","supplier_name":"Mustermann GmbH"}
Erlaubte Werte:
- buchungstyp: "Ausgabe" oder "Einnahme"
- ust_satz: 0, 7 oder 19
- betrag_netto: Dezimalzahl mit Punkt als Trennzeichen
- betrag_netto: Dezimalzahl mit Punkt als Trennzeichen$eigenerHinweis
Verfuegbare Kategorie-Konten:
$kategorieList
@ -85,7 +90,7 @@ $zahlungsList
$beleginhalt
PROMPT;
$response = $this->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;
}

View file

@ -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,

View file

@ -14,7 +14,8 @@ $suppliers = $suppliers ?? [];
data-belegtyp="<?= $h($beleg['belegtyp']) ?>"
data-beleg-datum="<?= $h($beleg['beleg_datum'] ?? '') ?>"
data-rechnungs-nr="<?= $h($beleg['rechnungs_nr'] ?? '') ?>"
data-betrag-brutto="<?= $h($beleg['betrag_brutto'] ?? '') ?>">
data-betrag-brutto="<?= $h($beleg['betrag_brutto'] ?? '') ?>"
data-supplier-name="<?= $h($beleg['supplier_name'] ?? '') ?>">
<div class="card-body" style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;padding:.75rem 1.25rem">
<div>
<span class="text-muted text-sm">Belegnr.</span><br>
@ -25,8 +26,23 @@ $suppliers = $suppliers ?? [];
<span class="badge badge-gray"><?= $h($beleg['belegtyp']) ?></span>
</div>
<div>
<span class="text-muted text-sm">Lieferant</span><br>
<span class="text-muted text-sm">Lieferant<?= (!$beleg['locked'] && $beleg['supplier_name']) ? ' <span style="opacity:.4;font-size:.7rem">✎</span>' : '' ?></span><br>
<?php if (!$beleg['locked'] && $beleg['supplier_name']): ?>
<span class="inline-edit-trigger"
id="supplier-inline-trigger"
data-field="supplier_name" data-type="text"
data-raw="<?= $h($beleg['supplier_name']) ?>"
title="Klicken zum Bearbeiten">
<?= $h($beleg['supplier_name']) ?>
</span>
<?php elseif (!$beleg['locked']): ?>
<input type="text" id="supplier-persistent-input"
value="" placeholder="Lieferant eingeben…"
list="supplier-datalist" autocomplete="off"
style="font-size:inherit;padding:2px 5px;border:1px solid var(--color-border);border-radius:var(--radius);background:#fff;min-width:180px">
<?php else: ?>
<?= $beleg['supplier_name'] ? $h($beleg['supplier_name']) : '<span class="text-muted">–</span>' ?>
<?php endif; ?>
</div>
<div>
<span class="text-muted text-sm">Belegdatum<?= !$beleg['locked'] ? ' <span style="opacity:.4;font-size:.7rem">✎</span>' : '' ?></span><br>
@ -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 ?? [];
</div>
<div class="form-group" title="Nur bei Telefon/Internet 19% wählbar" style="margin-bottom:.75rem">
<span id="eigenanteil_group"
style="display:inline-flex;align-items:center;gap:.5rem;opacity:.35;pointer-events:none">
<label id="eigenanteil_group"
style="display:inline-flex;align-items:center;gap:.5rem;opacity:.35;pointer-events:none;cursor:not-allowed">
<input type="checkbox" id="eigenanteil_30_cb" name="eigenanteil_30" value="1" disabled>
<span>30% Eigenanteil buchen</span>
</span>
</label>
</div>
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
@ -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 = <?= (int)$beleg['id'] ?>;
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
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 ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '')
: '';
const details =
(v.buchungstyp ? v.buchungstyp + ' · ' : '') +
'USt ' + v.ust_satz + ' % · ' +
'Kategorie: ' + katTxt + ' · ' +
'Zahlung: ' + zhlTxt + supTxt;
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center">' +
'<span>KI-Kontierungsvorschlag – Formular vorausgefüllt. Bitte prüfen und ggf. anpassen.</span>' +
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Neu generieren</button>' +
'<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>';
}
@ -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 () {
});
</script>
<?php endif; ?>
<?php if (!$beleg['locked']): ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
const _sup = document.getElementById('supplier-persistent-input');
if (!_sup) return;
const _bar = _sup.closest('[data-beleg-id]');
async function _saveSupplier() {
const newValue = _sup.value.trim();
if (!_bar) return;
const body = new URLSearchParams({
rechnungs_nr: _bar.dataset.rechnungsNr || '',
belegtyp: _bar.dataset.belegtyp || '',
beleg_datum: _bar.dataset.belegDatum || '',
betrag_brutto: _bar.dataset.betragBrutto || '',
supplier_name: newValue,
});
try {
const res = await fetch('/belege/<?= (int)$beleg['id'] ?>/meta', {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded'},
body: body.toString(),
});
if (!res.ok) throw new Error();
if (newValue) {
_bar.dataset.supplierName = newValue;
// Hidden-Metaformular synchron halten
const hiddenInput = document.getElementById('supplier_name_input');
if (hiddenInput) hiddenInput.value = newValue;
// Persistentes Input durch Inline-Trigger ersetzen (= visuelle Bestätigung)
const span = document.createElement('span');
span.className = 'inline-edit-trigger';
span.id = 'supplier-inline-trigger';
span.dataset.field = 'supplier_name';
span.dataset.type = 'text';
span.dataset.raw = newValue;
span.title = 'Klicken zum Bearbeiten';
span.textContent = newValue;
_sup.replaceWith(span);
}
} catch (_) {
// Speichern fehlgeschlagen – Feld bleibt editierbar
}
}
_sup.addEventListener('blur', _saveSupplier);
_sup.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); _sup.blur(); }
});
});
</script>
<?php endif; ?>

View file

@ -1,5 +1,94 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<!-- Drag & Drop Upload -->
<div id="bl-dnd-zone" style="
border:2px dashed var(--color-border);
border-radius:var(--radius);
background:var(--color-surface);
padding:2rem;
text-align:center;
margin-bottom:1.5rem;
cursor:pointer;
transition:border-color .15s, background .15s;
position:relative;
">
<input type="file" id="bl-dnd-file-input" accept=".pdf,.png,.jpg,.jpeg,.tif,.tiff,.xml"
style="position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%">
<div id="bl-dnd-icon" style="font-size:2rem;line-height:1;margin-bottom:.5rem;pointer-events:none">📎</div>
<div style="font-weight:600;pointer-events:none">Beleg hierher ziehen oder klicken</div>
<div class="text-muted text-sm" style="pointer-events:none">PDF, PNG, JPEG, TIFF, XML · max. 20 MB</div>
<div id="bl-dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div>
</div>
<script>
(function () {
const zone = document.getElementById('bl-dnd-zone');
const input = document.getElementById('bl-dnd-file-input');
const status = document.getElementById('bl-dnd-status');
const icon = document.getElementById('bl-dnd-icon');
function setActive(on) {
zone.style.borderColor = on ? 'var(--color-primary)' : 'var(--color-border)';
zone.style.background = on ? '#eff6ff' : 'var(--color-surface)';
}
function setStatus(msg, color) {
status.innerHTML = `<span style="color:${color || 'var(--color-muted)'}">${msg}</span>`;
}
zone.addEventListener('dragover', (e) => { e.preventDefault(); setActive(true); });
zone.addEventListener('dragleave', (e) => { if (!zone.contains(e.relatedTarget)) setActive(false); });
zone.addEventListener('drop', (e) => {
e.preventDefault();
setActive(false);
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
});
input.addEventListener('change', () => {
if (input.files[0]) uploadFile(input.files[0]);
input.value = '';
});
function uploadFile(file) {
const allowed = ['application/pdf','image/png','image/jpeg','image/tiff',
'application/xml','text/xml'];
if (!allowed.includes(file.type) && !file.name.match(/\.(pdf|png|jpe?g|tif{1,2}|xml)$/i)) {
setStatus('Nicht unterstütztes Dateiformat.', 'var(--color-danger)');
return;
}
icon.textContent = '⏳';
setStatus('Wird hochgeladen…');
zone.style.pointerEvents = 'none';
const fd = new FormData();
fd.append('beleg_file', file);
fd.append('belegtyp', 'Eingangsrechnung');
fd.append('eingangs_datum', new Date().toISOString().slice(0,10));
fd.append('beleg_datum', new Date().toISOString().slice(0,10));
fetch('/belege/upload', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd,
})
.then(async (res) => {
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Unbekannter Fehler');
icon.textContent = '✅';
setStatus(`${data.beleg_nr} hochgeladen – weiterleiten…`, 'var(--color-success)');
setTimeout(() => { window.location.href = '/belege/' + data.id; }, 600);
})
.catch((err) => {
icon.textContent = '📎';
zone.style.pointerEvents = '';
setStatus('Fehler: ' + err.message, 'var(--color-danger)');
});
}
})();
</script>
<div class="toolbar">
<div class="toolbar-left">
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">