From a077e03bee6157486dd7f4cf8a9583d828af1179 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 19 Aug 2026 14:57:09 +0200 Subject: [PATCH] Fix #40: Kategorie im Kontoblatt ohne Neuladen speichern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Ändern von Kategorie oder Klasse im Kontoblatt lud bisher die ganze Seite neu, um die Zeile zu aktualisieren – dabei sprang der Bildschirm nach ganz oben. kontoblatt.js schickt die Auswahl jetzt per fetch im Hintergrund; Kategorie, Klasse und das Symbol der Bargeld-Abgleichspalte aktualisieren sich an Ort und Stelle, die Bildlaufposition bleibt stehen. Ohne JavaScript bleibt es bei der bisherigen Weiterleitung. --- Changes.md | 19 +++++++ public/static/app.js | 29 +++++++++- public/static/kontoblatt.js | 73 ++++++++++++++++++++++++- src/Controllers/BaseController.php | 19 +++++++ src/Controllers/BuchungenController.php | 43 +++++++++++++-- src/View.php | 29 ++++++++++ src/Views/buchungen/kontoblatt.php | 27 ++------- tests/05_DarstellungTest.php | 35 ++++++++++++ tests/e2e/specs/buchungen.spec.js | 32 +++++++++++ 9 files changed, 276 insertions(+), 30 deletions(-) diff --git a/Changes.md b/Changes.md index 97d6330..39d3818 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,24 @@ # Änderungen +## Kontoblatt: Schnellzuordnung ohne Neuladen (19.08.2026) + +Das Ändern von Kategorie oder Klasse im Kontoblatt lud bisher die ganze Seite neu, um die +Zeile zu aktualisieren – dabei sprang der Bildschirm nach ganz oben. Bei einer älteren +Buchung weit unten in der Liste musste man nach jeder Änderung wieder dorthin zurückscrollen +(Fehler #40). + +kontoblatt.js schickt die Auswahl jetzt im Hintergrund per `fetch`; Kategorie- bzw. +Klassenspalte und – falls eingerichtet – das Symbol der Bargeld-Abgleichspalte aktualisieren +sich an Ort und Stelle, die Bildlaufposition bleibt stehen. Die Meldung „Zuordnung +gespeichert.“ erscheint wie gewohnt, jetzt über `window.FmMeldungen` (app.js) ohne +Seitenwechsel. + +Serverseitig unterscheiden `BuchungenController::zuordnung()` und `::klasse()` per +`X-Requested-With: XMLHttpRequest`, ob eine JSON-Antwort erwartet wird oder die bisherige +Weiterleitung mit Flash-Meldung – Letztere bleibt der einzige Weg ohne JavaScript. Schlägt +die Hintergrundanfrage fehl (Netzwerk, abgelaufene Anmeldung), übernimmt wie bisher eine +echte Formularübermittlung. + ## Bargeld-Tool: Adresse mit `/api/v1` (19.08.2026) Der Verbindungstest scheiterte mit „Das Bargeld-Tool kennt die Schnittstelle nicht diff --git a/public/static/app.js b/public/static/app.js index b0935d6..bc91ce5 100644 --- a/public/static/app.js +++ b/public/static/app.js @@ -295,12 +295,37 @@ // Erfolgsmeldungen gehen von selbst; Fehler und Warnungen bleiben stehen, // bis sie weggeklickt werden. Sie liegen über der Ansicht – ohne diesen Weg // verdeckten sie die Werkzeugleiste bis zum nächsten Seitenwechsel. - document.querySelectorAll('.meldung').forEach(function (element) { + const einrichten = function (element) { element.title = 'Zum Ausblenden anklicken'; element.addEventListener('click', function () { ausblenden(element); }); if (element.classList.contains('meldung-erfolg')) { window.setTimeout(function () { ausblenden(element); }, 5000); } - }); + }; + + document.querySelectorAll('.meldung').forEach(einrichten); + + // Für Ansichten, die eine Meldung nachträglich zeigen, ohne die Seite neu + // zu laden – etwa das Kontoblatt nach einer Schnellzuordnung + // (kontoblatt.js). Den Meldungsbereich selbst gibt es im HTML nur, wenn + // der Server ihn beim Laden der Seite mitgab; hier entsteht er bei Bedarf. + window.FmMeldungen = { + zeigen: function (text, typ) { + let bereich = document.querySelector('.meldungen'); + if (!bereich) { + bereich = document.createElement('div'); + bereich.className = 'meldungen'; + document.body.appendChild(bereich); + } + + const element = document.createElement('div'); + element.className = 'meldung meldung-' + (typ || 'erfolg'); + element.setAttribute('role', 'status'); + element.textContent = text; + bereich.appendChild(element); + + einrichten(element); + } + }; })(); diff --git a/public/static/kontoblatt.js b/public/static/kontoblatt.js index b6f1184..8955650 100644 --- a/public/static/kontoblatt.js +++ b/public/static/kontoblatt.js @@ -167,6 +167,52 @@ window.location.href = '/buchungen/' + feld.dataset.buchung + '/wertpapier?depot=' + depotId; } + /* Die Beschriftung der gewählten Option entspricht Wort für Wort dem, was + * die Zeile nach dem Speichern zeigen soll – Kategorie- und Kontonamen + * kommen serverseitig aus denselben Listen (zuordnungsliste.php). Der + * erste Eintrag „— keine —“ steht für eine leere Zuordnung. */ + function beschriftungAus(auswahlliste) { + if (auswahlliste.value === '') { + return ''; + } + return auswahlliste.options[auswahlliste.selectedIndex].textContent.trim(); + } + + /* Speichert eine Schnellzuordnung im Hintergrund, statt das Formular + * abzuschicken: Eine echte Formularübermittlung lädt die Seite neu und + * reißt den Bildschirm nach oben – bei einer alten Buchung weit unten in + * der Liste ein langer Weg zurück (Fehler #40). Das Feld schließt sofort; + * seinen Inhalt trägt die Antwort nach. + * + * Ohne Netz oder JSON-Antwort (etwa eine abgelaufene Anmeldung) übernimmt + * eine echte Formularübermittlung wie bisher – die Änderung geht dann + * nicht verloren, nur der Bildschirm springt in diesem Ausnahmefall. + */ + function schnellSpeichern(formular, aktualisieren) { + const daten = new FormData(formular); + schliessen(); + + fetch(formular.action, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + body: daten + }).then(function (antwort) { + return antwort.json().then(function (ergebnis) { + if (ergebnis.typ === 'fehler') { + window.FmMeldungen.zeigen(ergebnis.meldung, 'fehler'); + return; + } + + aktualisieren(ergebnis); + if (ergebnis.meldung) { + window.FmMeldungen.zeigen(ergebnis.meldung, 'erfolg'); + } + }); + }).catch(function () { + formular.requestSubmit(); + }); + } + liste.addEventListener('change', function () { if (!offenesFeld || offenesFormular !== auswahl) { return; @@ -194,7 +240,21 @@ // Eine Rückfrage wegen verlorener Splitzeilen braucht es hier nicht mehr: // Für eine aufgeteilte Buchung öffnet sich diese Liste gar nicht erst. - auswahl.requestSubmit(); + const wert = liste.value; + const beschriftung = beschriftungAus(liste); + + schnellSpeichern(auswahl, function (ergebnis) { + feld.dataset.wert = wert; + feld.title = beschriftung !== '' ? beschriftung : 'Kategorie oder Gegenkonto zuordnen'; + feld.textContent = beschriftung; + + if (ergebnis.bargeldHtml) { + const bargeldzelle = feld.closest('tr').querySelector('.bargeldspalte'); + if (bargeldzelle) { + bargeldzelle.innerHTML = ergebnis.bargeldHtml; + } + } + }); }); /* Die gewählte Klasse wird sofort gespeichert – der erste Eintrag der Liste @@ -205,7 +265,16 @@ if (!offenesFeld || offenesFormular !== klassenauswahl) { return; } - klassenauswahl.requestSubmit(); + + const feld = offenesFeld; + const wert = klassenliste.value; + const beschriftung = beschriftungAus(klassenliste); + + schnellSpeichern(klassenauswahl, function () { + feld.dataset.klasse = wert === '' ? '0' : wert; + feld.title = beschriftung !== '' ? beschriftung : 'Klasse zuordnen'; + feld.textContent = beschriftung; + }); }); } diff --git a/src/Controllers/BaseController.php b/src/Controllers/BaseController.php index 904c324..2e60685 100644 --- a/src/Controllers/BaseController.php +++ b/src/Controllers/BaseController.php @@ -77,4 +77,23 @@ abstract class BaseController $wert = $_GET[$feld] ?? $standard; return is_string($wert) ? trim($wert) : $standard; } + + /** + * Ob die Anfrage per JavaScript im Hintergrund geschickt wurde, statt als + * gewöhnliche Formularübermittlung. Nur dann erwartet der Aufrufer JSON + * statt einer Weiterleitung – ohne JavaScript bleibt der bisherige Weg + * über Location-Header und Flash-Meldung der einzige. + */ + protected function istAjax(): bool + { + return ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest'; + } + + /** @param array $daten */ + protected function json(array $daten): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($daten, JSON_UNESCAPED_UNICODE); + exit; + } } diff --git a/src/Controllers/BuchungenController.php b/src/Controllers/BuchungenController.php index a276253..f79cdcf 100644 --- a/src/Controllers/BuchungenController.php +++ b/src/Controllers/BuchungenController.php @@ -231,7 +231,7 @@ class BuchungenController extends BaseController $zuordnung['gegenkonto_id'] ); } catch (\Throwable $e) { - View::redirect($ziel, $e->getMessage(), 'fehler'); + $this->schnellantwort($ziel, $e->getMessage(), 'fehler'); return; } @@ -239,8 +239,21 @@ class BuchungenController extends BaseController // zu prüfen ist. Das Ergebnis landet an der Buchung und steht danach // als Symbol im Kontoblatt. $abgleich = BargeldAbgleich::pruefe((int)$buchung['id']); + $bargeldHtml = null; + if (BargeldAbgleich::eingerichtet()) { + $frisch = Buchung::find((int)$buchung['id']); + $bargeldHtml = View::bargeldMarke( + $frisch['bargeld_status'] ?? null, + $frisch['bargeld_hinweis'] ?? null + ); + } - View::redirect($ziel, 'Zuordnung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich)); + $this->schnellantwort( + $ziel, + 'Zuordnung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich), + 'erfolg', + ['bargeldHtml' => $bargeldHtml] + ); } /** @@ -265,11 +278,33 @@ class BuchungenController extends BaseController try { Buchung::klasseSetzen((int)$buchung['id'], $klasseId > 0 ? $klasseId : null); } catch (\Throwable $e) { - View::redirect($ziel, $e->getMessage(), 'fehler'); + $this->schnellantwort($ziel, $e->getMessage(), 'fehler'); return; } - View::redirect($ziel, $klasseId > 0 ? 'Klasse gespeichert.' : 'Klasse gelöscht.'); + $this->schnellantwort($ziel, $klasseId > 0 ? 'Klasse gespeichert.' : 'Klasse gelöscht.'); + } + + /** + * Antwort auf eine Schnellzuordnung aus dem Kontoblatt: kontoblatt.js + * schickt sie im Hintergrund und braucht nur die Flash-Meldung als JSON, + * um die Seite ohne Neuladen an Ort und Stelle zu aktualisieren – Text und + * Zuordnung der Zelle kennt es selbst aus der gewählten Option. Ohne + * JavaScript bleibt es bei der bisherigen Weiterleitung. + * + * @param array $daten zusätzliche Felder für die JSON-Antwort + */ + private function schnellantwort(string $ziel, string $meldung, string $typ = 'erfolg', array $daten = []): void + { + if ($this->istAjax()) { + if ($typ === 'fehler') { + http_response_code(422); + } + $this->json($daten + ['meldung' => $meldung, 'typ' => $typ]); + return; + } + + View::redirect($ziel, $meldung, $typ); } /** diff --git a/src/View.php b/src/View.php index 0fbd80d..6d92631 100644 --- a/src/View.php +++ b/src/View.php @@ -122,6 +122,35 @@ class View return (float)$wert < 0 ? 'betrag negativ' : 'betrag'; } + /** + * Das Symbol der Abgleichspalte im Kontoblatt. Vier Zustände, in dieser + * Reihenfolge zu lesen: gefunden, nicht gefunden, Abruf gescheitert – und + * der graue Kreis für alles, was mit dem Bargeld-Tool nichts zu tun hat. + * + * Die Zeichen stehen als Text und nicht als Grafik da: Sie erben damit + * Schriftgröße und – beim Ausrufezeichen ausdrücklich gewünscht – die + * Farbe des übrigen Textes, in jedem Farbschema. + * + * Eigenständige Methode statt einer Schleife im Kontoblatt, weil auch die + * Schnellzuordnung (kontoblatt.js) dasselbe Symbol braucht, ohne die Seite + * neu zu laden. + */ + public static function bargeldMarke(?string $status, ?string $hinweis): string + { + $hinweis = trim((string)$hinweis); + + [$zeichen, $klasse, $standardtitel] = match ($status) { + 'gefunden' => ['✓', 'gefunden', 'Im Bargeld-Tool gefunden'], + 'fehlt' => ['✗', 'fehlt', 'Im Bargeld-Tool nicht gefunden'], + 'fehler' => ['!', 'fehler', 'Der Abruf beim Bargeld-Tool ist gescheitert'], + default => ['●', 'neutral', 'Wird nicht mit dem Bargeld-Tool abgeglichen'], + }; + + return '' . $zeichen . ''; + } + /** * Farbschema für das data-thema-Attribut: 'hell', 'dunkel' oder '' für die * Systemvorgabe. Fällt auf die Systemvorgabe zurück, wenn die Einstellung diff --git a/src/Views/buchungen/kontoblatt.php b/src/Views/buchungen/kontoblatt.php index 9b106ea..a1110c2 100644 --- a/src/Views/buchungen/kontoblatt.php +++ b/src/Views/buchungen/kontoblatt.php @@ -106,31 +106,14 @@ $splituebersicht = static function (array $splitzeilen) use ($kategorieNamen, $k }; /** - * Das Symbol der Abgleichspalte. Vier Zustände, in dieser Reihenfolge zu lesen: - * gefunden, nicht gefunden, Abruf gescheitert – und der graue Kreis für alles, - * was mit dem Bargeld-Tool nichts zu tun hat. - * - * Die Zeichen stehen als Text und nicht als Grafik da: Sie erben damit - * Schriftgröße und – beim Ausrufezeichen ausdrücklich gewünscht – die Farbe - * des übrigen Textes, in jedem Farbschema. + * Das Symbol der Abgleichspalte, siehe View::bargeldMarke() – die Schnell- + * zuordnung (kontoblatt.js) braucht dasselbe Symbol für eine einzelne Zeile, + * ohne die Seite neu zu laden. * * @param array $zeile */ -$bargeldmarke = static function (array $zeile): string { - $status = (string)($zeile['bargeld_status'] ?? ''); - $hinweis = trim((string)($zeile['bargeld_hinweis'] ?? '')); - - [$zeichen, $klasse, $standardtitel] = match ($status) { - 'gefunden' => ['✓', 'gefunden', 'Im Bargeld-Tool gefunden'], - 'fehlt' => ['✗', 'fehlt', 'Im Bargeld-Tool nicht gefunden'], - 'fehler' => ['!', 'fehler', 'Der Abruf beim Bargeld-Tool ist gescheitert'], - default => ['●', 'neutral', 'Wird nicht mit dem Bargeld-Tool abgeglichen'], - }; - - return '' . $zeichen . ''; -}; +$bargeldmarke = static fn(array $zeile): string + => View::bargeldMarke($zeile['bargeld_status'] ?? null, $zeile['bargeld_hinweis'] ?? null); include __DIR__ . '/../layout/kopf.php'; ?> diff --git a/tests/05_DarstellungTest.php b/tests/05_DarstellungTest.php index 6dd7704..5998abf 100644 --- a/tests/05_DarstellungTest.php +++ b/tests/05_DarstellungTest.php @@ -13,6 +13,7 @@ class DarstellungTest extends TestCase $this->monatsnamen(); $this->betraege(); $this->datumsformat(); + $this->bargeldMarke(); } private function monatsnamen(): void @@ -56,4 +57,38 @@ class DarstellungTest extends TestCase $this->assertEquals('18.03.2026', View::datum('2026-03-18 14:22:01'), 'Zeitanteil wird abgeschnitten'); $this->assertEquals('–', View::datum(null), 'Leeres Datum als Gedankenstrich'); } + + /** + * Das Symbol der Bargeld-Abgleichspalte – eigenständig in View, damit die + * Schnellzuordnung im Kontoblatt es auch für die Ajax-Antwort bilden kann, + * ohne die Seite neu zu laden. + */ + private function bargeldMarke(): void + { + $gefunden = View::bargeldMarke('gefunden', null); + $this->assert(str_contains($gefunden, 'bargeld-gefunden'), 'Gefunden bekommt seine Klasse'); + $this->assert(str_contains($gefunden, '✓'), 'Und sein Zeichen'); + $this->assert(str_contains($gefunden, 'Im Bargeld-Tool gefunden'), 'Und den Standardtitel'); + + $fehlt = View::bargeldMarke('fehlt', null); + $this->assert(str_contains($fehlt, 'bargeld-fehlt'), 'Fehlt bekommt seine Klasse'); + $this->assert(str_contains($fehlt, '✗'), 'Und sein Zeichen'); + + $fehler = View::bargeldMarke('fehler', 'Zeitüberschreitung'); + $this->assert(str_contains($fehler, 'bargeld-fehler'), 'Fehler bekommt seine Klasse'); + $this->assert(str_contains($fehler, '!'), 'Und sein Zeichen'); + $this->assert(str_contains($fehler, 'Zeitüberschreitung'), 'Ein eigener Hinweis geht vor dem Standardtitel'); + + $neutral = View::bargeldMarke(null, null); + $this->assert(str_contains($neutral, 'bargeld-neutral'), 'Ohne Status der graue Kreis'); + $this->assert(str_contains($neutral, '●'), 'Und sein Zeichen'); + + $unbekannt = View::bargeldMarke('unfug', null); + $this->assertEquals($neutral, $unbekannt, 'Ein unbekannter Status gilt wie kein Status'); + + $this->assert( + str_contains(View::bargeldMarke('gefunden', '