30% Eigenanteil für Telefon und Internet + Tests mit KI Beleg-Erkennung
This commit is contained in:
parent
b8ef04033c
commit
926d4a5ee0
12 changed files with 397 additions and 62 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
.git/
|
||||||
|
*.md
|
||||||
|
tests/
|
||||||
|
uploads/
|
||||||
45
Changes.md
45
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
|
## 2026-03-29 – EÜR: Spaltenauswahl nach Kontenart statt Buchungstyp
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y \
|
||||||
unzip \
|
unzip \
|
||||||
curl \
|
curl \
|
||||||
poppler-utils \
|
poppler-utils \
|
||||||
|
tesseract-ocr \
|
||||||
|
tesseract-ocr-deu \
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
&& docker-php-ext-install pdo pdo_mysql zip gd \
|
&& docker-php-ext-install pdo pdo_mysql zip gd \
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
|
||||||
29
README.md
29
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.
|
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.
|
Nach dem ersten Login unter **Einstellungen** Firmendaten, Logo und ELSTER-Steuernummer eintragen. Diese Angaben erscheinen auf Ausgangsrechnungen und im EÜR-Export.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ services:
|
||||||
DB_USER: steuerkram
|
DB_USER: steuerkram
|
||||||
DB_PASS: steuerkram
|
DB_PASS: steuerkram
|
||||||
APP_SECRET: change-this-in-production
|
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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
10
migrations/012_konten_1800_8922.sql
Normal file
10
migrations/012_konten_1800_8922.sql
Normal 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);
|
||||||
|
|
@ -346,7 +346,7 @@ td { padding: .65rem 1rem; vertical-align: middle; }
|
||||||
/* ── Beleg Detail (Split-Pane) ───────────────────────────────── */
|
/* ── Beleg Detail (Split-Pane) ───────────────────────────────── */
|
||||||
.beleg-layout {
|
.beleg-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 420px;
|
grid-template-columns: 1fr 520px;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
@ -362,7 +362,7 @@ td { padding: .65rem 1rem; vertical-align: middle; }
|
||||||
.beleg-preview iframe,
|
.beleg-preview iframe,
|
||||||
.beleg-preview img {
|
.beleg-preview img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 700px;
|
min-height: 1165px;
|
||||||
border: none;
|
border: none;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,10 @@ document.addEventListener('click', (e) => {
|
||||||
input.type = type;
|
input.type = type;
|
||||||
input.value = rawValue;
|
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');
|
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;
|
const originalHTML = trigger.innerHTML;
|
||||||
trigger.innerHTML = '';
|
trigger.innerHTML = '';
|
||||||
|
|
@ -285,10 +289,11 @@ document.addEventListener('click', (e) => {
|
||||||
const newValue = input.value.trim();
|
const newValue = input.value.trim();
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
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 || '',
|
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 || '',
|
betrag_brutto: bar.dataset.betragBrutto || '',
|
||||||
|
supplier_name: field === 'supplier_name' ? newValue : bar.dataset.supplierName || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -309,6 +314,11 @@ document.addEventListener('click', (e) => {
|
||||||
} else {
|
} else {
|
||||||
trigger.innerHTML = '<span class="text-muted">– setzen –</span>';
|
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 {
|
} else {
|
||||||
bar.dataset.rechnungsNr = newValue;
|
bar.dataset.rechnungsNr = newValue;
|
||||||
trigger.dataset.raw = newValue;
|
trigger.dataset.raw = newValue;
|
||||||
|
|
@ -353,6 +363,7 @@ document.addEventListener('keydown', (e) => {
|
||||||
// ── Flash Message Auto-dismiss ────────────────────────────────
|
// ── Flash Message Auto-dismiss ────────────────────────────────
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.querySelectorAll('.flash').forEach(el => {
|
document.querySelectorAll('.flash').forEach(el => {
|
||||||
|
if (el.closest('#ki-badge')) return; // KI-Badge nie auto-dismissen
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
el.style.transition = 'opacity .4s';
|
el.style.transition = 'opacity .4s';
|
||||||
el.style.opacity = '0';
|
el.style.opacity = '0';
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ namespace Steuerkram\Services;
|
||||||
|
|
||||||
use Steuerkram\Models\Account;
|
use Steuerkram\Models\Account;
|
||||||
use Steuerkram\Models\Setting;
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Models\Supplier;
|
||||||
|
|
||||||
class KontierungsvorschlagService
|
class KontierungsvorschlagService
|
||||||
{
|
{
|
||||||
|
|
@ -28,28 +29,26 @@ class KontierungsvorschlagService
|
||||||
if (!file_exists($absPath)) return null;
|
if (!file_exists($absPath)) return null;
|
||||||
|
|
||||||
// Inhalt extrahieren
|
// Inhalt extrahieren
|
||||||
$base64Image = null;
|
|
||||||
$mime = strtolower($beleg['mime_type'] ?? '');
|
$mime = strtolower($beleg['mime_type'] ?? '');
|
||||||
|
|
||||||
if ($mime === 'application/pdf') {
|
if ($mime === 'application/pdf') {
|
||||||
$text = $this->extractPdfText($absPath);
|
$text = $this->extractPdfText($absPath);
|
||||||
} elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) {
|
} elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) {
|
||||||
$base64Image = base64_encode(file_get_contents($absPath));
|
$text = $this->extractImageText($absPath);
|
||||||
$text = null;
|
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Konten laden
|
// Konten laden (max. 50 relevante Konten für kurzen Prompt)
|
||||||
$allAccounts = Account::all(['aktiv' => 1]);
|
$allAccounts = Account::all(['aktiv' => 1]);
|
||||||
$kategorieKonten = array_filter(
|
$kategorieKonten = array_slice(array_values(array_filter(
|
||||||
$allAccounts,
|
$allAccounts,
|
||||||
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
||||||
);
|
)), 0, 40);
|
||||||
$zahlungsKonten = array_filter(
|
$zahlungsKonten = array_slice(array_values(array_filter(
|
||||||
$allAccounts,
|
$allAccounts,
|
||||||
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
||||||
);
|
)), 0, 10);
|
||||||
|
|
||||||
// Kontenliste für Prompt aufbauen
|
// Kontenliste für Prompt aufbauen
|
||||||
$kategorieList = implode("\n", array_map(
|
$kategorieList = implode("\n", array_map(
|
||||||
|
|
@ -61,20 +60,26 @@ class KontierungsvorschlagService
|
||||||
$zahlungsKonten
|
$zahlungsKonten
|
||||||
));
|
));
|
||||||
|
|
||||||
$beleginhalt = $text !== null && trim($text) !== ''
|
$beleginhalt = trim($text) !== ''
|
||||||
? "Belegtext:\n" . mb_substr(trim($text), 0, 3000)
|
? "Belegtext:\n" . mb_substr(trim($text), 0, 2000)
|
||||||
: '[Bilddatei – Inhalt wird direkt analysiert]';
|
: '[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
|
$prompt = <<<PROMPT
|
||||||
Du bist ein Buchhalter (SKR03-Kontenrahmen, Deutschland). Analysiere den folgenden Beleg und erstelle einen Kontierungsvorschlag.
|
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:
|
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:
|
Erlaubte Werte:
|
||||||
- buchungstyp: "Ausgabe" oder "Einnahme"
|
- buchungstyp: "Ausgabe" oder "Einnahme"
|
||||||
- ust_satz: 0, 7 oder 19
|
- ust_satz: 0, 7 oder 19
|
||||||
- betrag_netto: Dezimalzahl mit Punkt als Trennzeichen
|
- betrag_netto: Dezimalzahl mit Punkt als Trennzeichen$eigenerHinweis
|
||||||
|
|
||||||
Verfuegbare Kategorie-Konten:
|
Verfuegbare Kategorie-Konten:
|
||||||
$kategorieList
|
$kategorieList
|
||||||
|
|
@ -85,7 +90,7 @@ $zahlungsList
|
||||||
$beleginhalt
|
$beleginhalt
|
||||||
PROMPT;
|
PROMPT;
|
||||||
|
|
||||||
$response = $this->llm->generate($prompt, $base64Image);
|
$response = $this->llm->generate($prompt);
|
||||||
if ($response === '') return null;
|
if ($response === '') return null;
|
||||||
|
|
||||||
return $this->parseAndValidate($response, $allAccounts);
|
return $this->parseAndValidate($response, $allAccounts);
|
||||||
|
|
@ -99,6 +104,21 @@ PROMPT;
|
||||||
return $returnCode === 0 ? implode("\n", $output) : '';
|
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
|
private function parseAndValidate(string $response, array $accounts): ?array
|
||||||
{
|
{
|
||||||
// JSON aus Antwort extrahieren (Modell könnte Prosa voranstellen)
|
// 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['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['betrag_netto'] = round((float)$data['betrag_netto'], 2);
|
||||||
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
$data['buchungstext'] = (string)($data['buchungstext'] ?? '');
|
||||||
|
$data['supplier_name'] = trim((string)($data['supplier_name'] ?? ''));
|
||||||
|
|
||||||
// IDs gegen Kontenliste validieren
|
// IDs gegen Kontenliste validieren (mit Kontenartenprüfung)
|
||||||
$accountIds = array_column($accounts, 'id');
|
$kategorieIds = array_column(array_filter($accounts,
|
||||||
if (!in_array($data['kategorie_konto_id'], $accountIds, false)) return null;
|
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
||||||
if (!in_array($data['zahlungskonto_id'], $accountIds, false)) return null;
|
), '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;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
return match($this->mode) {
|
||||||
'ollama' => $this->callOllama($prompt, $base64Image),
|
'ollama' => $this->callOllama($prompt),
|
||||||
'openai' => $this->callOpenAi($prompt, $base64Image),
|
'openai' => $this->callOpenAi($prompt),
|
||||||
default => '',
|
default => '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -93,22 +93,19 @@ class LlmService
|
||||||
return $err === 0 && $code >= 200 && $code < 300;
|
return $err === 0 && $code >= 200 && $code < 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function callOllama(string $prompt, ?string $base64Image): string
|
private function callOllama(string $prompt): string
|
||||||
{
|
{
|
||||||
$payload = [
|
$payload = [
|
||||||
'model' => $this->model,
|
'model' => $this->model,
|
||||||
'prompt' => $prompt,
|
'prompt' => $prompt,
|
||||||
'stream' => false,
|
'stream' => false,
|
||||||
];
|
];
|
||||||
if ($base64Image !== null) {
|
|
||||||
$payload['images'] = [$base64Image];
|
|
||||||
}
|
|
||||||
|
|
||||||
$ch = curl_init($this->url . '/api/generate');
|
$ch = curl_init($this->url . '/api/generate');
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => 120,
|
CURLOPT_TIMEOUT => 300,
|
||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
]);
|
]);
|
||||||
|
|
@ -120,27 +117,18 @@ class LlmService
|
||||||
return json_decode($response, true)['response'] ?? '';
|
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 = [
|
$payload = [
|
||||||
'model' => $this->model,
|
'model' => $this->model,
|
||||||
'messages' => [['role' => 'user', 'content' => $content]],
|
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||||
];
|
];
|
||||||
|
|
||||||
$ch = curl_init($this->url . '/chat/completions');
|
$ch = curl_init($this->url . '/chat/completions');
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => 120,
|
CURLOPT_TIMEOUT => 300,
|
||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => [
|
||||||
'Content-Type: application/json',
|
'Content-Type: application/json',
|
||||||
'Authorization: Bearer ' . $this->apiKey,
|
'Authorization: Bearer ' . $this->apiKey,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ $suppliers = $suppliers ?? [];
|
||||||
data-belegtyp="<?= $h($beleg['belegtyp']) ?>"
|
data-belegtyp="<?= $h($beleg['belegtyp']) ?>"
|
||||||
data-beleg-datum="<?= $h($beleg['beleg_datum'] ?? '') ?>"
|
data-beleg-datum="<?= $h($beleg['beleg_datum'] ?? '') ?>"
|
||||||
data-rechnungs-nr="<?= $h($beleg['rechnungs_nr'] ?? '') ?>"
|
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 class="card-body" style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;padding:.75rem 1.25rem">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted text-sm">Belegnr.</span><br>
|
<span class="text-muted text-sm">Belegnr.</span><br>
|
||||||
|
|
@ -25,8 +26,23 @@ $suppliers = $suppliers ?? [];
|
||||||
<span class="badge badge-gray"><?= $h($beleg['belegtyp']) ?></span>
|
<span class="badge badge-gray"><?= $h($beleg['belegtyp']) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
<?= $beleg['supplier_name'] ? $h($beleg['supplier_name']) : '<span class="text-muted">–</span>' ?>
|
<?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>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted text-sm">Belegdatum<?= !$beleg['locked'] ? ' <span style="opacity:.4;font-size:.7rem">✎</span>' : '' ?></span><br>
|
<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'
|
$beleg['belegtyp'] === 'Ausgangsrechnung' ? 'Einnahme' : 'Ausgabe'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Konten nach Art gruppieren
|
// Konten nach Art gruppieren (Zahlungskonto nur 1200–1299)
|
||||||
$kontenGruppen = [];
|
$kontenGruppen = [];
|
||||||
foreach ($accounts as $acc) {
|
foreach ($accounts as $acc) {
|
||||||
|
if ($acc['kontenart'] === 'Zahlungskonto') {
|
||||||
|
$nr = (int)$acc['kontonummer'];
|
||||||
|
if ($nr < 1200 || $nr > 1299) continue;
|
||||||
|
}
|
||||||
$kontenGruppen[$acc['kontenart']][] = $acc;
|
$kontenGruppen[$acc['kontenart']][] = $acc;
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
@ -416,11 +436,11 @@ $suppliers = $suppliers ?? [];
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" title="Nur bei Telefon/Internet 19% wählbar" style="margin-bottom:.75rem">
|
<div class="form-group" title="Nur bei Telefon/Internet 19% wählbar" style="margin-bottom:.75rem">
|
||||||
<span id="eigenanteil_group"
|
<label id="eigenanteil_group"
|
||||||
style="display:inline-flex;align-items:center;gap:.5rem;opacity:.35;pointer-events:none">
|
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>
|
<input type="checkbox" id="eigenanteil_30_cb" name="eigenanteil_30" value="1" disabled>
|
||||||
<span>30% Eigenanteil buchen</span>
|
<span>30% Eigenanteil buchen</span>
|
||||||
</span>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
|
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
|
||||||
|
|
@ -475,7 +495,7 @@ function updateEigenanteilState() {
|
||||||
|
|
||||||
group.style.opacity = aktiv ? '1' : '.35';
|
group.style.opacity = aktiv ? '1' : '.35';
|
||||||
group.style.pointerEvents = aktiv ? '' : 'none';
|
group.style.pointerEvents = aktiv ? '' : 'none';
|
||||||
group.querySelector('label').style.cursor = aktiv ? 'pointer' : 'not-allowed';
|
group.style.cursor = aktiv ? 'pointer' : 'not-allowed';
|
||||||
cb.disabled = !aktiv;
|
cb.disabled = !aktiv;
|
||||||
if (!aktiv) cb.checked = false;
|
if (!aktiv) cb.checked = false;
|
||||||
}
|
}
|
||||||
|
|
@ -486,6 +506,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (checked) filterKategorieKonten(checked.value);
|
if (checked) filterKategorieKonten(checked.value);
|
||||||
updateSteuerartVisibility();
|
updateSteuerartVisibility();
|
||||||
updateEigenanteilState();
|
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() {
|
function updateSteuerartVisibility() {
|
||||||
|
|
@ -509,26 +533,70 @@ function updateSteuerartVisibility() {
|
||||||
const _belegId = <?= (int)$beleg['id'] ?>;
|
const _belegId = <?= (int)$beleg['id'] ?>;
|
||||||
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
|
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) {
|
function _applyKiVorschlag(v) {
|
||||||
const btRadio = document.querySelector('input[name="buchungstyp"][value="' + v.buchungstyp + '"]');
|
const btRadio = document.querySelector('input[name="buchungstyp"][value="' + v.buchungstyp + '"]');
|
||||||
if (btRadio) { btRadio.checked = true; filterKategorieKonten(v.buchungstyp); }
|
if (btRadio) { btRadio.checked = true; filterKategorieKonten(v.buchungstyp); }
|
||||||
const ustEl = document.getElementById('ust_satz_select');
|
const ustEl = document.getElementById('ust_satz_select');
|
||||||
if (ustEl) { ustEl.value = String(v.ust_satz); updateSteuerartVisibility(); }
|
if (ustEl) { ustEl.value = String(v.ust_satz); updateSteuerartVisibility(); }
|
||||||
const katEl = document.getElementById('kategorie_konto_id');
|
const katEl2 = document.getElementById('kategorie_konto_id');
|
||||||
if (katEl) { katEl.value = String(v.kategorie_konto_id); updateEigenanteilState(); }
|
_selectById(katEl2, v.kategorie_konto_id);
|
||||||
const zhlEl = document.getElementById('zahlungskonto_id');
|
if (katEl2) transferKontoNr(katEl2);
|
||||||
if (zhlEl) zhlEl.value = String(v.zahlungskonto_id);
|
updateEigenanteilState();
|
||||||
|
const zhlEl2 = document.getElementById('zahlungskonto_id');
|
||||||
|
_selectById(zhlEl2, v.zahlungskonto_id);
|
||||||
|
if (zhlEl2) transferKontoNr(zhlEl2);
|
||||||
const netEl = document.querySelector('input[name="betrag_netto"]');
|
const netEl = document.querySelector('input[name="betrag_netto"]');
|
||||||
if (netEl && v.betrag_netto) netEl.value = v.betrag_netto;
|
if (netEl && v.betrag_netto) netEl.value = v.betrag_netto;
|
||||||
const txtEl = document.querySelector('input[name="buchungstext"]');
|
const txtEl = document.querySelector('input[name="buchungstext"]');
|
||||||
if (txtEl && v.buchungstext) txtEl.value = v.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 =
|
document.getElementById('ki-badge').innerHTML =
|
||||||
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center">' +
|
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center;gap:1rem">' +
|
||||||
'<span>KI-Kontierungsvorschlag – Formular vorausgefüllt. Bitte prüfen und ggf. anpassen.</span>' +
|
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span></div>' +
|
||||||
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Neu generieren</button>' +
|
'<button class="btn btn-secondary btn-sm" style="flex-shrink:0" onclick="_startKiAnalyse()">Neu generieren</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -549,7 +617,7 @@ async function _startKiAnalyse() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
|
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
|
||||||
const v = await r.json();
|
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();
|
else _showKiBadgeError();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showKiBadgeError();
|
_showKiBadgeError();
|
||||||
|
|
@ -559,13 +627,13 @@ async function _startKiAnalyse() {
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) {
|
if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) {
|
||||||
_applyKiVorschlag(_kiVorschlag);
|
_applyKiVorschlag(_kiVorschlag);
|
||||||
_showKiBadgeDone();
|
_showKiBadgeDone(_kiVorschlag);
|
||||||
} else {
|
} else {
|
||||||
_showKiBadgeLoading();
|
_showKiBadgeLoading();
|
||||||
fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'})
|
fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(v => {
|
.then(v => {
|
||||||
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(); }
|
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
|
||||||
else _showKiBadgeError();
|
else _showKiBadgeError();
|
||||||
})
|
})
|
||||||
.catch(() => _showKiBadgeError());
|
.catch(() => _showKiBadgeError());
|
||||||
|
|
@ -573,3 +641,54 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
<?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; ?>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,94 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
<?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">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
|
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue