Fix #40: Kategorie im Kontoblatt ohne Neuladen speichern

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.
This commit is contained in:
fds2610-bot 2026-08-19 14:57:09 +02:00
parent 18c1603dbd
commit a077e03bee
9 changed files with 276 additions and 30 deletions

View file

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

View file

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

View file

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

View file

@ -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<string,mixed> $daten */
protected function json(array $daten): void
{
header('Content-Type: application/json; charset=utf-8');
echo json_encode($daten, JSON_UNESCAPED_UNICODE);
exit;
}
}

View file

@ -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<string,mixed> $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);
}
/**

View file

@ -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 '<span class="bargeldmarke bargeld-' . $klasse . '" title="'
. self::h($hinweis !== '' ? $hinweis : $standardtitel) . '" role="img" aria-label="'
. self::h($standardtitel) . '">' . $zeichen . '</span>';
}
/**
* Farbschema für das data-thema-Attribut: 'hell', 'dunkel' oder '' für die
* Systemvorgabe. Fällt auf die Systemvorgabe zurück, wenn die Einstellung

View file

@ -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<string,mixed> $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 '<span class="bargeldmarke bargeld-' . $klasse . '" title="'
. View::h($hinweis !== '' ? $hinweis : $standardtitel) . '" role="img" aria-label="'
. View::h($standardtitel) . '">' . $zeichen . '</span>';
};
$bargeldmarke = static fn(array $zeile): string
=> View::bargeldMarke($zeile['bargeld_status'] ?? null, $zeile['bargeld_hinweis'] ?? null);
include __DIR__ . '/../layout/kopf.php';
?>

View file

@ -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', '<script>'), '&lt;script&gt;'),
'Ein Hinweis wird maskiert'
);
}
}

View file

@ -181,6 +181,38 @@ test.describe('Buchungen', () => {
.toContainText(KATEGORIE)
})
// Fehler #40: Das Speichern lud bisher die ganze Seite neu und riss den
// Bildschirm nach oben – bei einer Buchung weiter unten in der Liste ein
// langer Weg zurück. Die Markierung übersteht nur, wenn nichts neu lädt.
test('Kategorie ändern lädt die Seite nicht neu und lässt die Bildlaufposition stehen', async ({ page }) => {
// Ein niedriges Fenster erzwingt Bildlauf, unabhängig davon, wie viele
// Buchungen die vorigen Tests schon angelegt haben.
await page.setViewportSize({ width: 1280, height: 400 })
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-05')
await page.fill('#betrag', '5,55')
await page.fill('#empfaenger', `Kein Neuladen ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
await page.goto(`/konten/${giroId}`)
await page.evaluate(() => { window.scrollTo(0, 300) })
await page.evaluate(() => { window.__e2eKeinNeuladen = true })
const zeile = page.locator('tr', { hasText: `Kein Neuladen ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: KATEGORIE })
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung')
await expect(zeile).toContainText(KATEGORIE)
// Ein echtes Neuladen hätte sowohl die Markierung gelöscht als auch die
// Bildlaufposition auf null zurückgesetzt.
expect(await page.evaluate(() => window.__e2eKeinNeuladen)).toBe(true)
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0)
})
test('Klasse im Kontoblatt zuordnen und wieder löschen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-06')