diff --git a/Changes.md b/Changes.md index 73d46b2..1fa7536 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,26 @@ --- +## 2026-06-29 – Feature: Ereignisse-Log für Nextcloud-Import (Issue #109) + +- **`migrations/083_system_events.sql`:** Neue Tabelle `system_events` (id, channel, message, created_at) +- **`src/Models/EventLog.php`:** Neues Model mit `add()` und `findByChannel()` +- **`src/Services/NextcloudImportService.php`:** Persistiert relevante Ereignisse (Import, Duplikat, Fehler) automatisch; stille Läufe ohne Dateiaktivität erzeugen keine Einträge; Zusammenfassung wird nur bei tatsächlicher Aktivität geloggt +- **`src/Controllers/EventController.php`:** Neuer Controller für `/ereignisse` und `/ereignisse/nextcloud-import` +- **`src/Views/ereignisse/nextcloud_import.php`:** Log-Ansicht mit Zeitstempel, Kanal und farbiger Meldung +- **`src/Views/layout/nav.php`:** Neuer Menüpunkt „Ereignisse" unter Verwaltung mit aufklappbarem Unterpunkt „Nextcloud-Import" + +--- + +## 2026-06-29 – Feature: Lieferant in Belegliste inline bearbeiten (Issue #108) + +- **`src/Views/belege/list.php`:** Lieferant-Spalte ist direkt in der Liste klickbar; zeigt ein Inline-Textfeld zur Eingabe, speichert mit Enter, bricht mit Escape ab; andere Spalten öffnen weiterhin die Vorschau +- **`src/Models/Beleg.php`:** Neue Methode `updateSupplier()` aktualisiert nur die `supplier_id` ohne andere Metadaten zu verändern +- **`src/Controllers/BelegController.php`:** Neue Methode `updateSupplier()` als AJAX-Endpunkt +- **`public/index.php`:** Route `POST /belege/{id}/supplier` registriert + +--- + ## 2026-06-11 – Fix: Undefined-Array-Key-Warning bei Buchungsvorlagen ohne Standard-Lieferant - **`src/Models/Buchungsvorlage.php`:** `create()`/`update()` prüften `$data['standard_lieferant']` direkt ohne Null-Coalescing, was bei fehlendem Schlüssel eine PHP-Warning auslöste diff --git a/migrations/083_system_events.sql b/migrations/083_system_events.sql new file mode 100644 index 0000000..cc6d149 --- /dev/null +++ b/migrations/083_system_events.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS system_events ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + channel VARCHAR(50) NOT NULL, + message TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_channel_created (channel, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/public/index.php b/public/index.php index 8a9ef53..09b54ef 100644 --- a/public/index.php +++ b/public/index.php @@ -28,6 +28,7 @@ use Steuerkram\Controllers\AssetController; use Steuerkram\Controllers\UserAdminController; use Steuerkram\Controllers\BankController; use Steuerkram\Controllers\BuchungsvorlageController; +use Steuerkram\Controllers\EventController; Config::load(); @@ -60,8 +61,9 @@ $router->post('/belege/upload', fn($p) => (new BelegController())->upload($p)); $router->get('/belege/{id}', fn($p) => (new BelegController())->show($p)); $router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p)); $router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p)); -$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p)); -$router->post('/belege/{id}/crop', fn($p) => (new BelegController())->cropImage($p)); +$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p)); +$router->post('/belege/{id}/supplier', fn($p) => (new BelegController())->updateSupplier($p)); +$router->post('/belege/{id}/crop', fn($p) => (new BelegController())->cropImage($p)); $router->post('/belege/{id}/vorschlag/generate', fn($p) => (new BelegController())->generateVorschlag($p)); $router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($p)); @@ -183,6 +185,10 @@ $router->post('/einstellungen/buchungsvorlagen/{id}/loeschen', fn($p) = $router->get('/api/buchungsvorlagen/{id}/modal', fn($p) => (new BuchungsvorlageController())->modal($p)); $router->post('/belege/{belegId}/vorlage/{vorlageId}/buchen', fn($p) => (new BuchungsvorlageController())->buchen($p)); +// ── Ereignisse ─────────────────────────────────────────────── +$router->get('/ereignisse', fn($p) => (new EventController())->index($p)); +$router->get('/ereignisse/nextcloud-import', fn($p) => (new EventController())->nextcloudImport($p)); + // ── Login / Logout ──────────────────────────────────────────── $router->get('/login', fn($p) => (new LoginController())->showLogin($p)); $router->post('/login', fn($p) => (new LoginController())->handleLogin($p)); diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 4d145f9..eb98f58 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -366,6 +366,30 @@ class BelegController extends BaseController View::redirect("/belege/{$id}", 'Metadaten gespeichert.'); } + public function updateSupplier(array $params): void + { + header('Content-Type: application/json'); + $id = (int)$params['id']; + $beleg = Beleg::find($id); + + if (!$beleg) { + http_response_code(404); + echo json_encode(['error' => 'Nicht gefunden.']); + return; + } + if ($beleg['locked']) { + http_response_code(403); + echo json_encode(['error' => 'GoBD: Gesperrter Beleg.']); + return; + } + + $supplierName = trim($_POST['supplier_name'] ?? ''); + $supplierId = $supplierName !== '' ? Supplier::findOrCreate($supplierName) : null; + Beleg::updateSupplier($id, $supplierId); + + echo json_encode(['ok' => true, 'supplier_name' => $supplierName !== '' ? $supplierName : null]); + } + public function generateVorschlag(array $params): void { header('Content-Type: application/json'); diff --git a/src/Controllers/EventController.php b/src/Controllers/EventController.php new file mode 100644 index 0000000..c989236 --- /dev/null +++ b/src/Controllers/EventController.php @@ -0,0 +1,29 @@ +render('layout/base.php', [ + 'pageTitle' => 'Ereignisse – Nextcloud-Import', + 'content' => $this->renderView('ereignisse/nextcloud_import', [ + 'events' => $events, + ]), + ]); + } +} diff --git a/src/Models/Beleg.php b/src/Models/Beleg.php index aea9bc0..56781c7 100644 --- a/src/Models/Beleg.php +++ b/src/Models/Beleg.php @@ -231,6 +231,14 @@ class Beleg ); } + public static function updateSupplier(int $id, ?int $supplierId): void + { + Database::execute( + 'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=? AND locked=0', + [$supplierId, $id] + ); + } + public static function delete(int $id): bool { $beleg = self::find($id); diff --git a/src/Models/EventLog.php b/src/Models/EventLog.php new file mode 100644 index 0000000..6899012 --- /dev/null +++ b/src/Models/EventLog.php @@ -0,0 +1,30 @@ + 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.'; + $msg = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; + $result['details'][] = $msg; $result['errors']++; + $this->persistEvent($msg); return $result; } $files = $this->listFiles(); if ($files === null) { - $result['details'][] = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.'; + $msg = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.'; + $result['details'][] = $msg; $result['errors']++; + $this->persistEvent($msg); return $result; } @@ -90,9 +97,24 @@ class NextcloudImportService $this->processFile($file, $result); } + if ($result['imported'] + $result['skipped'] + $result['errors'] > 0) { + $elapsed = round(microtime(true) - $start, 2); + $summary = sprintf( + 'Fertig in %ss – importiert: %d, übersprungen: %d, Fehler: %d', + $elapsed, $result['imported'], $result['skipped'], $result['errors'] + ); + $this->persistEvent($summary); + $result['details'][] = $summary; + } + return $result; } + private function persistEvent(string $message): void + { + EventLog::add('nextcloud-import', $message); + } + /** * Listet alle Dateien im konfigurierten Ordner via PROPFIND. * Gibt null zurück bei Verbindungsfehler. @@ -198,7 +220,9 @@ class NextcloudImportService // MIME prüfen if (!isset(self::ALLOWED_MIME[$contentType])) { - $result['details'][] = "Übersprungen (unbekannter Typ «{$contentType}»): {$filename}"; + $msg = "Übersprungen (unbekannter Typ «{$contentType}»): {$filename}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['skipped']++; return; } @@ -210,7 +234,9 @@ class NextcloudImportService $tmpPath = sys_get_temp_dir() . '/nextcloud_import_' . uniqid() . '.' . $ext; if (!$this->downloadFile($fileUrl, $tmpPath)) { - $result['details'][] = "FEHLER beim Download: {$filename}"; + $msg = "FEHLER beim Download: {$filename}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['errors']++; return; } @@ -222,7 +248,9 @@ class NextcloudImportService [$rawHash] ); if ($existing) { - $result['details'][] = "Duplikat (Beleg {$existing['beleg_nr']}): {$filename}"; + $msg = "Duplikat (Beleg {$existing['beleg_nr']}): {$filename}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['skipped']++; unlink($tmpPath); $this->moveFile($file['href'], $filename); @@ -236,7 +264,9 @@ class NextcloudImportService $absDir = dirname(__DIR__, 2) . "/public/{$relDir}"; if (!is_dir($absDir) && !mkdir($absDir, 0755, true)) { - $result['details'][] = "FEHLER: Verzeichnis konnte nicht erstellt werden: {$absDir}"; + $msg = "FEHLER: Verzeichnis konnte nicht erstellt werden: {$absDir}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['errors']++; unlink($tmpPath); return; @@ -246,7 +276,9 @@ class NextcloudImportService $absPath = dirname(__DIR__, 2) . "/public/{$storagePath}"; if (!rename($tmpPath, $absPath)) { - $result['details'][] = "FEHLER: Datei konnte nicht verschoben werden: {$filename}"; + $msg = "FEHLER: Datei konnte nicht verschoben werden: {$filename}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['errors']++; @unlink($tmpPath); return; @@ -278,7 +310,9 @@ class NextcloudImportService // Datei in Nextcloud verschieben/löschen $this->moveFile($file['href'], $filename); - $result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}"; + $msg = "Importiert: {$filename} → Beleg {$belegNr}"; + $result['details'][] = $msg; + $this->persistEvent($msg); $result['imported']++; $result['imported_ids'][] = $belegId; } diff --git a/src/Views/belege/list.php b/src/Views/belege/list.php index 3487c6d..b7f1600 100644 --- a/src/Views/belege/list.php +++ b/src/Views/belege/list.php @@ -162,7 +162,13 @@
| = $h(date('Y-m-d H:i:s', strtotime($e['created_at']))) ?> | +[= $h($e['channel']) ?>] | += $h($msg) ?> | +