Merge pull request 'Fix #40: Kategorie im Kontoblatt ohne Neuladen speichern' (#42) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/42
This commit is contained in:
commit
1c3c0de604
9 changed files with 276 additions and 30 deletions
19
Changes.md
19
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
29
src/View.php
29
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 '<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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
?>
|
||||
|
|
|
|||
|
|
@ -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>'), '<script>'),
|
||||
'Ein Hinweis wird maskiert'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue